console-window 1.0.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.
- console_window-1.0.0/.gitignore +207 -0
- console_window-1.0.0/LICENSE +21 -0
- console_window-1.0.0/PKG-INFO +107 -0
- console_window-1.0.0/README.md +91 -0
- console_window-1.0.0/console_window/ConsoleWindow.py +778 -0
- console_window-1.0.0/console_window/__init__.py +1 -0
- console_window-1.0.0/pyproject.toml +47 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
env/
|
|
142
|
+
venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Joe D
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: console-window
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A robust curses wrapper for creating Text User Interfaces (TUIs).
|
|
5
|
+
Author-email: Joe Defen <joedef@duck.com>
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Topic :: Terminals
|
|
13
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# console-window
|
|
18
|
+
|
|
19
|
+
**A robust and feature-rich wrapper for the standard Python `curses` library, simplifying the creation of complex, full-screen Text User Interface (TUI) applications.**
|
|
20
|
+
|
|
21
|
+
This package abstracts away the complexities of managing curses pads, dynamic screen resizing, custom scroll/pick synchronization, and keyboard handling, allowing developers to focus purely on content.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Install the package via `pip`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install console-window
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Core Features (The "Secret Sauce")
|
|
32
|
+
|
|
33
|
+
The wrapper handles several advanced terminal interaction patterns automatically:
|
|
34
|
+
|
|
35
|
+
* **Padded Content Management:** Uses separate `head` and `body` pads, allowing for a static header area while the main content area can be scrolled independently.
|
|
36
|
+
* **Seamless Resizing:** Automatically detects terminal size changes (`KEY_RESIZE`) and recalculates layout dimensions, with robust error handling to prevent common `curses.error` crashes during resizing.
|
|
37
|
+
* **Scroll & Pick Synchronization:** The `Window` class manages **scroll position** and a separate **pick position** (highlighted row) simultaneously. When using pick mode, scrolling automatically tracks the highlighted element to keep it visible.
|
|
38
|
+
* **Intuitive Navigation:** Provides built-in handling for common navigation keys (`j`/`k`, `UP`/`DOWN`, Page Up/Down, `H`/`M`/`L` for home/middle/last viewable line, etc.).
|
|
39
|
+
* **Dynamic Options Management (`OptionSpinner`):** The companion class simplifies creation of application settings, allowing users to **toggle options** or input strings using single keypresses (e.g., `p` to toggle pick mode).
|
|
40
|
+
* **Blocking Popups:** Includes fully implemented, blocking pop-up methods for user input (`.answer()`) and alerts (`.alert()`), handling the necessary temporary screen takeover and cursor management.
|
|
41
|
+
|
|
42
|
+
## Minimal Working Example
|
|
43
|
+
|
|
44
|
+
This example demonstrates setting up the window, using the `OptionSpinner` for control, and running the main render/prompt loop.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import curses
|
|
48
|
+
from ConsoleWindow import Window, OptionSpinner, SimpleNamespace
|
|
49
|
+
|
|
50
|
+
def main_app_loop(stdscr):
|
|
51
|
+
# 1. Setup Options Manager
|
|
52
|
+
spin = OptionSpinner()
|
|
53
|
+
opts = spin.default_obj # Access options via this namespace
|
|
54
|
+
|
|
55
|
+
# Add features controlled by keypresses
|
|
56
|
+
spin.add_key('help_mode', '? - toggle help screen', vals=[False, True])
|
|
57
|
+
spin.add_key('pick_mode', 'p - toggle pick mode', vals=[False, True])
|
|
58
|
+
spin.add_key('pick_size', 's - #rows in pick', vals=[1, 2, 3])
|
|
59
|
+
spin.add_key('name', 'n - select name', prompt='Provide Your Name:')
|
|
60
|
+
|
|
61
|
+
# 2. Initialize Window
|
|
62
|
+
win = Window(head_line=True, keys=spin.keys)
|
|
63
|
+
opts.name = "[hit 'n' to enter name]"
|
|
64
|
+
loop_count = 0
|
|
65
|
+
|
|
66
|
+
while True:
|
|
67
|
+
loop_count += 1
|
|
68
|
+
|
|
69
|
+
# 3. Add Content
|
|
70
|
+
if opts.help_mode:
|
|
71
|
+
win.set_pick_mode(False)
|
|
72
|
+
spin.show_help_nav_keys(win)
|
|
73
|
+
spin.show_help_body(win)
|
|
74
|
+
else:
|
|
75
|
+
win.set_pick_mode(opts.pick_mode, opts.pick_size)
|
|
76
|
+
win.add_header(f'TUI App Header | Loop: {loop_count} | Name: "{opts.name}"')
|
|
77
|
+
|
|
78
|
+
# Add body content (scrollable)
|
|
79
|
+
for idx in range(100):
|
|
80
|
+
win.add_body(f'Main Item {idx+1}')
|
|
81
|
+
|
|
82
|
+
# 4. Render and Prompt for Input
|
|
83
|
+
win.render()
|
|
84
|
+
key = win.prompt(seconds=0.5) # Wait for half a second or a keypress
|
|
85
|
+
|
|
86
|
+
# 5. Handle Keys (App-specific logic)
|
|
87
|
+
if key is not None:
|
|
88
|
+
# Check if OptionSpinner can handle the key (p, s, n, ?)
|
|
89
|
+
spin.do_key(key, win)
|
|
90
|
+
|
|
91
|
+
if key in (ord('q'), ord('Q')):
|
|
92
|
+
break # Exit the loop
|
|
93
|
+
|
|
94
|
+
win.clear()
|
|
95
|
+
|
|
96
|
+
if __name__ == '__main__':
|
|
97
|
+
try:
|
|
98
|
+
# Note: Window.__init__ starts curses and registers atexit cleanup
|
|
99
|
+
curses.wrapper(main_app_loop)
|
|
100
|
+
except KeyboardInterrupt:
|
|
101
|
+
pass
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
MIT
|
|
106
|
+
|
|
107
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
|
|
2
|
+
# console-window
|
|
3
|
+
|
|
4
|
+
**A robust and feature-rich wrapper for the standard Python `curses` library, simplifying the creation of complex, full-screen Text User Interface (TUI) applications.**
|
|
5
|
+
|
|
6
|
+
This package abstracts away the complexities of managing curses pads, dynamic screen resizing, custom scroll/pick synchronization, and keyboard handling, allowing developers to focus purely on content.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Install the package via `pip`:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install console-window
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Core Features (The "Secret Sauce")
|
|
17
|
+
|
|
18
|
+
The wrapper handles several advanced terminal interaction patterns automatically:
|
|
19
|
+
|
|
20
|
+
* **Padded Content Management:** Uses separate `head` and `body` pads, allowing for a static header area while the main content area can be scrolled independently.
|
|
21
|
+
* **Seamless Resizing:** Automatically detects terminal size changes (`KEY_RESIZE`) and recalculates layout dimensions, with robust error handling to prevent common `curses.error` crashes during resizing.
|
|
22
|
+
* **Scroll & Pick Synchronization:** The `Window` class manages **scroll position** and a separate **pick position** (highlighted row) simultaneously. When using pick mode, scrolling automatically tracks the highlighted element to keep it visible.
|
|
23
|
+
* **Intuitive Navigation:** Provides built-in handling for common navigation keys (`j`/`k`, `UP`/`DOWN`, Page Up/Down, `H`/`M`/`L` for home/middle/last viewable line, etc.).
|
|
24
|
+
* **Dynamic Options Management (`OptionSpinner`):** The companion class simplifies creation of application settings, allowing users to **toggle options** or input strings using single keypresses (e.g., `p` to toggle pick mode).
|
|
25
|
+
* **Blocking Popups:** Includes fully implemented, blocking pop-up methods for user input (`.answer()`) and alerts (`.alert()`), handling the necessary temporary screen takeover and cursor management.
|
|
26
|
+
|
|
27
|
+
## Minimal Working Example
|
|
28
|
+
|
|
29
|
+
This example demonstrates setting up the window, using the `OptionSpinner` for control, and running the main render/prompt loop.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import curses
|
|
33
|
+
from ConsoleWindow import Window, OptionSpinner, SimpleNamespace
|
|
34
|
+
|
|
35
|
+
def main_app_loop(stdscr):
|
|
36
|
+
# 1. Setup Options Manager
|
|
37
|
+
spin = OptionSpinner()
|
|
38
|
+
opts = spin.default_obj # Access options via this namespace
|
|
39
|
+
|
|
40
|
+
# Add features controlled by keypresses
|
|
41
|
+
spin.add_key('help_mode', '? - toggle help screen', vals=[False, True])
|
|
42
|
+
spin.add_key('pick_mode', 'p - toggle pick mode', vals=[False, True])
|
|
43
|
+
spin.add_key('pick_size', 's - #rows in pick', vals=[1, 2, 3])
|
|
44
|
+
spin.add_key('name', 'n - select name', prompt='Provide Your Name:')
|
|
45
|
+
|
|
46
|
+
# 2. Initialize Window
|
|
47
|
+
win = Window(head_line=True, keys=spin.keys)
|
|
48
|
+
opts.name = "[hit 'n' to enter name]"
|
|
49
|
+
loop_count = 0
|
|
50
|
+
|
|
51
|
+
while True:
|
|
52
|
+
loop_count += 1
|
|
53
|
+
|
|
54
|
+
# 3. Add Content
|
|
55
|
+
if opts.help_mode:
|
|
56
|
+
win.set_pick_mode(False)
|
|
57
|
+
spin.show_help_nav_keys(win)
|
|
58
|
+
spin.show_help_body(win)
|
|
59
|
+
else:
|
|
60
|
+
win.set_pick_mode(opts.pick_mode, opts.pick_size)
|
|
61
|
+
win.add_header(f'TUI App Header | Loop: {loop_count} | Name: "{opts.name}"')
|
|
62
|
+
|
|
63
|
+
# Add body content (scrollable)
|
|
64
|
+
for idx in range(100):
|
|
65
|
+
win.add_body(f'Main Item {idx+1}')
|
|
66
|
+
|
|
67
|
+
# 4. Render and Prompt for Input
|
|
68
|
+
win.render()
|
|
69
|
+
key = win.prompt(seconds=0.5) # Wait for half a second or a keypress
|
|
70
|
+
|
|
71
|
+
# 5. Handle Keys (App-specific logic)
|
|
72
|
+
if key is not None:
|
|
73
|
+
# Check if OptionSpinner can handle the key (p, s, n, ?)
|
|
74
|
+
spin.do_key(key, win)
|
|
75
|
+
|
|
76
|
+
if key in (ord('q'), ord('Q')):
|
|
77
|
+
break # Exit the loop
|
|
78
|
+
|
|
79
|
+
win.clear()
|
|
80
|
+
|
|
81
|
+
if __name__ == '__main__':
|
|
82
|
+
try:
|
|
83
|
+
# Note: Window.__init__ starts curses and registers atexit cleanup
|
|
84
|
+
curses.wrapper(main_app_loop)
|
|
85
|
+
except KeyboardInterrupt:
|
|
86
|
+
pass
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
MIT
|
|
91
|
+
|
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Custom Wrapper for python curses.
|
|
5
|
+
"""
|
|
6
|
+
# pylint: disable=too-many-instance-attributes,too-many-arguments
|
|
7
|
+
# pylint: disable=invalid-name,broad-except,too-many-branches,global-statement
|
|
8
|
+
|
|
9
|
+
import traceback
|
|
10
|
+
import atexit
|
|
11
|
+
import signal
|
|
12
|
+
import time
|
|
13
|
+
import curses
|
|
14
|
+
import textwrap
|
|
15
|
+
from types import SimpleNamespace
|
|
16
|
+
from curses.textpad import rectangle, Textbox
|
|
17
|
+
dump_str = None
|
|
18
|
+
|
|
19
|
+
def ignore_ctrl_c():
|
|
20
|
+
"""" Ignore SIGINT """
|
|
21
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
22
|
+
|
|
23
|
+
def restore_ctrl_c():
|
|
24
|
+
"""" Handle SIGINT """
|
|
25
|
+
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
26
|
+
|
|
27
|
+
class OptionSpinner:
|
|
28
|
+
"""Manage a bunch of options where the value is rotate thru
|
|
29
|
+
a fixed set of values pressing a key."""
|
|
30
|
+
def __init__(self):
|
|
31
|
+
"""Give the object with the attribute to change its
|
|
32
|
+
value (e.g., options from argparse or "self" from
|
|
33
|
+
the object managing the window).
|
|
34
|
+
|
|
35
|
+
And array of specs like:
|
|
36
|
+
['a - allow auto suggestions', 'allow_auto', True, False],
|
|
37
|
+
['/ - filter pattern', 'filter_str', self.filter_str],
|
|
38
|
+
A spec can have a trailing None + more comments shown after
|
|
39
|
+
the value.
|
|
40
|
+
"""
|
|
41
|
+
self.options, self.keys = [], []
|
|
42
|
+
self.margin = 4 # + actual width (1st column right pos)
|
|
43
|
+
self.align = self.margin # + actual width (1st column right pos)
|
|
44
|
+
self.default_obj = SimpleNamespace() # if not given one
|
|
45
|
+
self.attr_to_option = {} # given an attribute, find its option ns
|
|
46
|
+
self.key_to_option = {} # given key, options namespace
|
|
47
|
+
self.keys = set()
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _make_option_ns():
|
|
51
|
+
return SimpleNamespace(
|
|
52
|
+
keys=[],
|
|
53
|
+
descr='',
|
|
54
|
+
obj=None,
|
|
55
|
+
attr='',
|
|
56
|
+
vals=None,
|
|
57
|
+
prompt=None,
|
|
58
|
+
comments=[],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def get_value(self, attr, coerce=False):
|
|
62
|
+
"""Get the value of the given attribute."""
|
|
63
|
+
ns = self.attr_to_option.get(attr, None)
|
|
64
|
+
obj = ns.obj if ns else None
|
|
65
|
+
value = getattr(obj, attr, None) if obj else None
|
|
66
|
+
if value is None and obj and coerce:
|
|
67
|
+
if ns.vals:
|
|
68
|
+
if value not in ns.vals:
|
|
69
|
+
value = ns.vals[0]
|
|
70
|
+
setattr(obj, attr, value)
|
|
71
|
+
else:
|
|
72
|
+
if value is None:
|
|
73
|
+
value = ''
|
|
74
|
+
setattr(ns.obj, ns.attr, '')
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
def _register(self, ns):
|
|
78
|
+
""" Create the mappings needed"""
|
|
79
|
+
assert ns.attr not in self.attr_to_option
|
|
80
|
+
self.attr_to_option[ns.attr] = ns
|
|
81
|
+
for key in ns.keys:
|
|
82
|
+
assert key not in self.key_to_option, f'key ({chr(key)}, {key}) already used'
|
|
83
|
+
self.key_to_option[key] = ns
|
|
84
|
+
self.keys.add(key)
|
|
85
|
+
self.options.append(ns)
|
|
86
|
+
self.align = max(self.align, self.margin+len(ns.descr))
|
|
87
|
+
self.get_value(ns.attr, coerce=True)
|
|
88
|
+
|
|
89
|
+
def add(self, obj, specs):
|
|
90
|
+
""" Compatibility Method."""
|
|
91
|
+
for spec in specs:
|
|
92
|
+
ns = self._make_option_ns()
|
|
93
|
+
ns.descr = spec[0]
|
|
94
|
+
ns.obj = obj
|
|
95
|
+
ns.attr = spec[1]
|
|
96
|
+
ns.vals=spec[2:]
|
|
97
|
+
if None in ns.vals:
|
|
98
|
+
idx = ns.vals.index(None)
|
|
99
|
+
ns.vals = ns.vals[:idx]
|
|
100
|
+
ns.comments = ns.vals[idx+1:]
|
|
101
|
+
ns.keys = [ord(ns.descr[0])]
|
|
102
|
+
self._register(ns)
|
|
103
|
+
|
|
104
|
+
def add_key(self, attr, descr, obj=None, vals=None, prompt=None, keys=None, comments=None):
|
|
105
|
+
""" Standard method"""
|
|
106
|
+
ns = self._make_option_ns()
|
|
107
|
+
if keys:
|
|
108
|
+
ns.keys = list(keys) if isinstance(keys, (list, tuple, set)) else [keys]
|
|
109
|
+
else:
|
|
110
|
+
ns.keys = [ord(descr[0])]
|
|
111
|
+
if comments is None:
|
|
112
|
+
ns.comments = []
|
|
113
|
+
else:
|
|
114
|
+
ns.comments = list(comments) if isinstance(keys, (list, tuple)) else [comments]
|
|
115
|
+
ns.descr = descr
|
|
116
|
+
ns.attr = attr
|
|
117
|
+
ns.obj = obj if obj else self.default_obj
|
|
118
|
+
ns.vals, ns.prompt = vals, prompt
|
|
119
|
+
assert bool(ns.vals) ^ bool(ns.prompt)
|
|
120
|
+
self._register(ns)
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def show_help_nav_keys(win):
|
|
124
|
+
"""For help screens, show the navigation keys. """
|
|
125
|
+
for line in ConsoleWindow.get_nav_keys_blurb().splitlines():
|
|
126
|
+
if line:
|
|
127
|
+
win.add_header(line)
|
|
128
|
+
|
|
129
|
+
def show_help_body(self, win):
|
|
130
|
+
""" Write the help page section."""
|
|
131
|
+
win.add_body('Type keys to alter choice:', curses.A_UNDERLINE)
|
|
132
|
+
|
|
133
|
+
for ns in self.options:
|
|
134
|
+
# get / coerce the current value
|
|
135
|
+
value = self.get_value(ns.attr)
|
|
136
|
+
assert value is not None, f'cannot get value of {repr(ns.attr)}'
|
|
137
|
+
choices = ns.vals if ns.vals else [value]
|
|
138
|
+
|
|
139
|
+
win.add_body(f'{ns.descr:>{self.align}}: ')
|
|
140
|
+
|
|
141
|
+
for choice in choices:
|
|
142
|
+
shown = f'{choice}'
|
|
143
|
+
if isinstance(choice, bool):
|
|
144
|
+
shown = "ON" if choice else "off"
|
|
145
|
+
win.add_body(' ', resume=True)
|
|
146
|
+
win.add_body(shown, resume=True,
|
|
147
|
+
attr=curses.A_REVERSE if choice == value else None)
|
|
148
|
+
|
|
149
|
+
for comment in ns.comments:
|
|
150
|
+
win.add_body(f'{"":>{self.align}}: {comment}')
|
|
151
|
+
|
|
152
|
+
def do_key(self, key, win):
|
|
153
|
+
"""Do the automated processing of a key."""
|
|
154
|
+
ns = self.key_to_option.get(key, None)
|
|
155
|
+
if ns is None:
|
|
156
|
+
return None
|
|
157
|
+
value = self.get_value(ns.attr)
|
|
158
|
+
if ns.vals:
|
|
159
|
+
idx = ns.vals.index(value) if value in ns.vals else -1
|
|
160
|
+
value = ns.vals[(idx+1) % len(ns.vals)] # choose next
|
|
161
|
+
else:
|
|
162
|
+
value = win.answer(prompt=ns.prompt, seed=str(value))
|
|
163
|
+
setattr(ns.obj, ns.attr, value)
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
class ConsoleWindow:
|
|
167
|
+
""" Layer above curses to encapsulate what we need """
|
|
168
|
+
timeout_ms = 200
|
|
169
|
+
static_scr = None
|
|
170
|
+
nav_keys = """
|
|
171
|
+
Navigation: H/M/L: top/middle/end-of-page
|
|
172
|
+
k, UP: up one row 0, HOME: first row
|
|
173
|
+
j, DOWN: down one row $, END: last row
|
|
174
|
+
Ctrl-u: half-page up Ctrl-b, PPAGE: page up
|
|
175
|
+
Ctrl-d: half-page down Ctrl-f, NPAGE: page down
|
|
176
|
+
"""
|
|
177
|
+
def __init__(self, head_line=True, head_rows=50, body_rows=200,
|
|
178
|
+
body_cols=200, keys=None, pick_mode=False, pick_size=1,
|
|
179
|
+
mod_pick=None, ctrl_c_terminates=True):
|
|
180
|
+
if ctrl_c_terminates:
|
|
181
|
+
# then never want to ignore_ctrl_c (so defeat the ignorer/restorer)
|
|
182
|
+
global ignore_ctrl_c, restore_ctrl_c
|
|
183
|
+
def noop():
|
|
184
|
+
return
|
|
185
|
+
ignore_ctrl_c = restore_ctrl_c = noop
|
|
186
|
+
|
|
187
|
+
self.scr = self._start_curses()
|
|
188
|
+
|
|
189
|
+
self.head = SimpleNamespace(
|
|
190
|
+
pad=curses.newpad(head_rows, body_cols),
|
|
191
|
+
rows=head_rows,
|
|
192
|
+
cols=body_cols,
|
|
193
|
+
row_cnt=0, # no. head rows added
|
|
194
|
+
texts = [],
|
|
195
|
+
view_cnt=0, # no. head rows viewable (NOT in body)
|
|
196
|
+
)
|
|
197
|
+
self.body = SimpleNamespace(
|
|
198
|
+
pad = curses.newpad(body_rows, body_cols),
|
|
199
|
+
rows= body_rows,
|
|
200
|
+
cols=body_cols,
|
|
201
|
+
row_cnt = 0,
|
|
202
|
+
texts = []
|
|
203
|
+
)
|
|
204
|
+
self.mod_pick = mod_pick # call back to modify highlighted row
|
|
205
|
+
self.hor_line_cnt = 1 if head_line else 0 # no. h-lines in header
|
|
206
|
+
self.scroll_pos = 0 # how far down into body are we?
|
|
207
|
+
self.max_scroll_pos = 0
|
|
208
|
+
self.pick_pos = 0 # in highlight mode, where are we?
|
|
209
|
+
self.last_pick_pos = -1 # last highlighted position
|
|
210
|
+
self.pick_mode = pick_mode # whether in highlight mode
|
|
211
|
+
self.pick_size = pick_size # whether in highlight mode
|
|
212
|
+
self.rows, self.cols = 0, 0
|
|
213
|
+
self.body_cols, self.body_rows = body_cols, body_rows
|
|
214
|
+
self.scroll_view_size = 0 # no. viewable lines of the body
|
|
215
|
+
self.handled_keys = set(keys) if isinstance(keys, (set, list)) else []
|
|
216
|
+
self._set_screen_dims()
|
|
217
|
+
self.calc()
|
|
218
|
+
|
|
219
|
+
def get_pad_width(self):
|
|
220
|
+
""" how much space to actually draw chars? """
|
|
221
|
+
return min(self.cols-1, self.body_cols)
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def get_nav_keys_blurb():
|
|
225
|
+
"""For a help screen, describe the nav keys"""
|
|
226
|
+
return textwrap.dedent(ConsoleWindow.nav_keys)
|
|
227
|
+
|
|
228
|
+
def _set_screen_dims(self):
|
|
229
|
+
"""Recalculate dimensions ... return True if geometry changed."""
|
|
230
|
+
rows, cols = self.scr.getmaxyx()
|
|
231
|
+
same = bool(rows == self.rows and cols == self.cols)
|
|
232
|
+
self.rows, self.cols = rows, cols
|
|
233
|
+
return same
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _start_curses():
|
|
237
|
+
""" Curses initial setup. Note: not using curses.wrapper because we
|
|
238
|
+
don't wish to change the colors. """
|
|
239
|
+
atexit.register(ConsoleWindow.stop_curses)
|
|
240
|
+
ignore_ctrl_c()
|
|
241
|
+
ConsoleWindow.static_scr = scr = curses.initscr()
|
|
242
|
+
curses.noecho()
|
|
243
|
+
curses.cbreak()
|
|
244
|
+
curses.curs_set(0)
|
|
245
|
+
scr.keypad(1)
|
|
246
|
+
scr.timeout(ConsoleWindow.timeout_ms)
|
|
247
|
+
scr.clear()
|
|
248
|
+
return scr
|
|
249
|
+
|
|
250
|
+
def set_pick_mode(self, on=True, pick_size=1):
|
|
251
|
+
"""Set whether in highlight mode."""
|
|
252
|
+
was_on, was_size = self.pick_mode, self.pick_size
|
|
253
|
+
self.pick_mode = bool(on)
|
|
254
|
+
self.pick_size = max(pick_size, 1)
|
|
255
|
+
if self.pick_mode and (not was_on or was_size != self.pick_size):
|
|
256
|
+
self.last_pick_pos = -2 # indicates need to clear them all
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def stop_curses():
|
|
260
|
+
""" Curses shutdown (registered to be called on exit). """
|
|
261
|
+
if ConsoleWindow.static_scr:
|
|
262
|
+
curses.nocbreak()
|
|
263
|
+
curses.echo()
|
|
264
|
+
ConsoleWindow.static_scr.keypad(0)
|
|
265
|
+
curses.endwin()
|
|
266
|
+
ConsoleWindow.static_scr = None
|
|
267
|
+
restore_ctrl_c()
|
|
268
|
+
|
|
269
|
+
def calc(self):
|
|
270
|
+
"""Recalculate dimensions ... return True if geometry changed."""
|
|
271
|
+
same = self._set_screen_dims()
|
|
272
|
+
self.head.view_cnt = min(self.rows - self.hor_line_cnt, self.head.row_cnt)
|
|
273
|
+
self.scroll_view_size = self.rows - self.head.view_cnt - self.hor_line_cnt
|
|
274
|
+
self.max_scroll_pos = max(self.body.row_cnt - self.scroll_view_size, 0)
|
|
275
|
+
self.body_base = self.head.view_cnt + self.hor_line_cnt
|
|
276
|
+
return not same
|
|
277
|
+
|
|
278
|
+
def _put(self, ns, *args):
|
|
279
|
+
""" Add text to head/body pad using its namespace. args:
|
|
280
|
+
- bytes (converted to str/unicode)
|
|
281
|
+
- str (unicode)
|
|
282
|
+
- None (same as curses.A_NORMAL)
|
|
283
|
+
- int (curses attribute)
|
|
284
|
+
"""
|
|
285
|
+
def flush(attr=None):
|
|
286
|
+
nonlocal self, is_body, row, text, seg, first
|
|
287
|
+
if (is_body and self.pick_mode) or attr is None:
|
|
288
|
+
attr = curses.A_NORMAL
|
|
289
|
+
if seg and first:
|
|
290
|
+
ns.pad.addstr(row, 0, seg[0:self.get_pad_width()], attr)
|
|
291
|
+
elif seg:
|
|
292
|
+
_, x = ns.pad.getyx()
|
|
293
|
+
cols = self.get_pad_width() - x
|
|
294
|
+
if cols > 0:
|
|
295
|
+
ns.pad.addstr(seg[0:cols], attr)
|
|
296
|
+
text += seg
|
|
297
|
+
seg, first, attr = '', False, None
|
|
298
|
+
|
|
299
|
+
is_body = bool(id(ns) == id(self.body))
|
|
300
|
+
if ns.row_cnt < ns.rows:
|
|
301
|
+
row = max(ns.row_cnt, 0)
|
|
302
|
+
text, seg, first = '', '', True
|
|
303
|
+
for arg in args:
|
|
304
|
+
if isinstance(arg, bytes):
|
|
305
|
+
arg = arg.decode('utf-8')
|
|
306
|
+
if isinstance(arg, str):
|
|
307
|
+
seg += arg # note: add w/o spacing
|
|
308
|
+
elif arg is None or isinstance(arg, (int)):
|
|
309
|
+
# assume arg is attribute ... flushes text
|
|
310
|
+
flush(attr=arg)
|
|
311
|
+
flush()
|
|
312
|
+
ns.texts.append(text) # text only history
|
|
313
|
+
ns.row_cnt += 1
|
|
314
|
+
|
|
315
|
+
def put_head(self, *args):
|
|
316
|
+
""" Put a line above the line."""
|
|
317
|
+
self._put(self.head, *args)
|
|
318
|
+
|
|
319
|
+
def put_body(self, *args):
|
|
320
|
+
""" Put a line below the line."""
|
|
321
|
+
self._put(self.body, *args)
|
|
322
|
+
|
|
323
|
+
def _add(self, ns, text, attr=None, resume=False):
|
|
324
|
+
""" Add text to head/body pad using its namespace"""
|
|
325
|
+
is_body = bool(id(ns) == id(self.body))
|
|
326
|
+
if ns.row_cnt < ns.rows:
|
|
327
|
+
row = max(ns.row_cnt - (1 if resume else 0), 0)
|
|
328
|
+
if (is_body and self.pick_mode) or attr is None:
|
|
329
|
+
attr = curses.A_NORMAL
|
|
330
|
+
if resume:
|
|
331
|
+
_, x = ns.pad.getyx()
|
|
332
|
+
cols = self.get_pad_width() - x
|
|
333
|
+
if cols > 0:
|
|
334
|
+
ns.pad.addstr(text[0:cols], attr)
|
|
335
|
+
ns.texts[row] += text
|
|
336
|
+
else:
|
|
337
|
+
ns.pad.addstr(row, 0, text[0:self.cols], attr)
|
|
338
|
+
ns.texts.append(text) # text only history
|
|
339
|
+
ns.row_cnt += 1
|
|
340
|
+
|
|
341
|
+
def add_header(self, text, attr=None, resume=False):
|
|
342
|
+
"""Add text to header"""
|
|
343
|
+
self._add(self.head, text, attr, resume)
|
|
344
|
+
|
|
345
|
+
def add_body(self, text, attr=None, resume=False):
|
|
346
|
+
""" Add text to body (below header and header line)"""
|
|
347
|
+
self._add(self.body, text, attr, resume)
|
|
348
|
+
|
|
349
|
+
def draw(self, y, x, text, text_attr=None, width=None, leftpad=False, header=False):
|
|
350
|
+
"""Draws the given text (as utf-8 or unicode) at position (row=y,col=x)
|
|
351
|
+
with optional text attributes and width.
|
|
352
|
+
This is more compatible with my older, simpler Window class.
|
|
353
|
+
"""
|
|
354
|
+
ns = self.head if header else self.body
|
|
355
|
+
text_attr = text_attr if text_attr else curses.A_NORMAL
|
|
356
|
+
if y < 0 or y >= ns.rows or x < 0 or x >= ns.cols:
|
|
357
|
+
return # nada if out of bounds
|
|
358
|
+
ns.row_cnt = max(ns.row_cnt, y+1)
|
|
359
|
+
|
|
360
|
+
uni = text if isinstance(text, str) else text.decode('utf-8')
|
|
361
|
+
|
|
362
|
+
if width is not None:
|
|
363
|
+
width = min(width, self.get_pad_width() - x)
|
|
364
|
+
if width <= 0:
|
|
365
|
+
return
|
|
366
|
+
padlen = width - len(uni)
|
|
367
|
+
if padlen > 0:
|
|
368
|
+
if leftpad:
|
|
369
|
+
uni = padlen * ' ' + uni
|
|
370
|
+
else: # rightpad
|
|
371
|
+
uni += padlen * ' '
|
|
372
|
+
text = uni[:width].encode('utf-8')
|
|
373
|
+
else:
|
|
374
|
+
text = uni.encode('utf-8')
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
while y >= len(ns.texts):
|
|
378
|
+
ns.texts.append('')
|
|
379
|
+
ns.texts[y] = ns.texts[y][:x].ljust(x) + uni + ns.texts[y][x+len(uni):]
|
|
380
|
+
ns.pad.addstr(y, x, text, text_attr)
|
|
381
|
+
except curses.error:
|
|
382
|
+
# this sucks, but curses returns an error if drawing the last character
|
|
383
|
+
# on the screen always. this can happen if resizing screen even if
|
|
384
|
+
# special care is taken. So, we just ignore errors. Anyhow, you cannot
|
|
385
|
+
# get decent error handling.
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def highlight_picked(self):
|
|
390
|
+
"""Highlight the current pick and un-highlight the previous pick."""
|
|
391
|
+
def get_text(pos):
|
|
392
|
+
nonlocal self
|
|
393
|
+
return self.body.texts[pos][0:self.cols] if pos < len(self.body.texts) else ''
|
|
394
|
+
|
|
395
|
+
if not self.pick_mode:
|
|
396
|
+
return
|
|
397
|
+
pos0, pos1 = self.last_pick_pos, self.pick_pos
|
|
398
|
+
if pos0 == -2: # special flag to clear all formatting
|
|
399
|
+
for row in range(self.body.row_cnt):
|
|
400
|
+
line = get_text(row).ljust(self.get_pad_width())
|
|
401
|
+
self.body.pad.addstr(row, 0, get_text(row), curses.A_NORMAL)
|
|
402
|
+
if pos0 != pos1:
|
|
403
|
+
if 0 <= pos0 < self.body.row_cnt:
|
|
404
|
+
for i in range(self.pick_size):
|
|
405
|
+
line = get_text(pos0+i).ljust(self.get_pad_width())
|
|
406
|
+
self.body.pad.addstr(pos0+i, 0, line, curses.A_NORMAL)
|
|
407
|
+
if 0 <= pos1 < self.body.row_cnt:
|
|
408
|
+
for i in range(self.pick_size):
|
|
409
|
+
line = get_text(pos1+i)
|
|
410
|
+
if self.mod_pick:
|
|
411
|
+
line = self.mod_pick(line)
|
|
412
|
+
line = line.ljust(self.get_pad_width())
|
|
413
|
+
self.body.pad.addstr(pos1+i, 0, line, curses.A_REVERSE)
|
|
414
|
+
self.last_pick_pos = pos1
|
|
415
|
+
|
|
416
|
+
def _scroll_indicator_row(self):
|
|
417
|
+
""" Compute the absolute scroll indicator row:
|
|
418
|
+
- We want the top to be only when scroll_pos==0
|
|
419
|
+
- We want the bottom to be only when scroll_pos=max_scroll_pos-1
|
|
420
|
+
"""
|
|
421
|
+
if self.max_scroll_pos <= 1:
|
|
422
|
+
return self.body_base
|
|
423
|
+
y2, y1 = self.scroll_view_size-1, 1
|
|
424
|
+
x2, x1 = self.max_scroll_pos, 1
|
|
425
|
+
x = self.scroll_pos
|
|
426
|
+
pos = y1 + (y2-y1)*(x-x1)/(x2-x1)
|
|
427
|
+
return min(self.body_base + int(max(pos, 0)), self.rows-1)
|
|
428
|
+
|
|
429
|
+
def _scroll_indicator_col(self):
|
|
430
|
+
""" Compute the absolute scroll indicator col:
|
|
431
|
+
- We want the left to be only when scroll_pos==0
|
|
432
|
+
- We want the right to be only when scroll_pos=max_scroll_pos-1
|
|
433
|
+
"""
|
|
434
|
+
if self.pick_mode:
|
|
435
|
+
return self._calc_indicator(
|
|
436
|
+
self.pick_pos, 0, self.body.row_cnt-1, 0, self.cols-1)
|
|
437
|
+
return self._calc_indicator(
|
|
438
|
+
self.scroll_pos, 0, self.max_scroll_pos, 0, self.cols-1)
|
|
439
|
+
|
|
440
|
+
def _calc_indicator(self, pos, pos0, pos9, ind0, ind9):
|
|
441
|
+
if self.max_scroll_pos <= 0:
|
|
442
|
+
return -1 # not scrollable
|
|
443
|
+
if pos9 - pos0 <= 0:
|
|
444
|
+
return -1 # not scrollable
|
|
445
|
+
if pos <= pos0:
|
|
446
|
+
return ind0
|
|
447
|
+
if pos >= pos9:
|
|
448
|
+
return ind9
|
|
449
|
+
ind = int(round(ind0 + (ind9-ind0+1)*(pos-pos0)/(pos9-pos0+1)))
|
|
450
|
+
return min(max(ind, ind0+1), ind9-1)
|
|
451
|
+
|
|
452
|
+
def render(self):
|
|
453
|
+
"""Draw everything added. In a loop cuz curses is a
|
|
454
|
+
piece of shit."""
|
|
455
|
+
for _ in range(128):
|
|
456
|
+
try:
|
|
457
|
+
self.render_once()
|
|
458
|
+
return
|
|
459
|
+
except curses.error:
|
|
460
|
+
time.sleep(0.16)
|
|
461
|
+
self._set_screen_dims()
|
|
462
|
+
continue
|
|
463
|
+
try:
|
|
464
|
+
self.render_once()
|
|
465
|
+
except Exception:
|
|
466
|
+
ConsoleWindow.stop_curses()
|
|
467
|
+
print(f"""curses err:
|
|
468
|
+
head.row_cnt={self.head.row_cnt}
|
|
469
|
+
head.view_cnt={self.head.view_cnt}
|
|
470
|
+
hor_line_cnt={self.hor_line_cnt}
|
|
471
|
+
body.row_cnt={self.body.row_cnt}
|
|
472
|
+
scroll_pos={self.scroll_pos}
|
|
473
|
+
max_scroll_pos={self.max_scroll_pos}
|
|
474
|
+
pick_pos={self.pick_pos}
|
|
475
|
+
last_pick_pos={self.last_pick_pos}
|
|
476
|
+
pick_mode={self.pick_mode}
|
|
477
|
+
pick_size={self.pick_size}
|
|
478
|
+
rows={self.rows}
|
|
479
|
+
cols={self.cols}
|
|
480
|
+
""")
|
|
481
|
+
raise
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def fix_positions(self, delta=0):
|
|
485
|
+
""" Ensure the vertical positions are on the playing field """
|
|
486
|
+
self.calc()
|
|
487
|
+
# if self.scroll_view_size <= 0:
|
|
488
|
+
# self.scr.refresh()
|
|
489
|
+
if self.pick_mode:
|
|
490
|
+
self.pick_pos += delta
|
|
491
|
+
else:
|
|
492
|
+
self.scroll_pos += delta
|
|
493
|
+
self.pick_pos += delta
|
|
494
|
+
|
|
495
|
+
indent = 0
|
|
496
|
+
if self.body_base < self.rows:
|
|
497
|
+
ind_pos = 0 if self.pick_mode else self._scroll_indicator_row()
|
|
498
|
+
if self.pick_mode:
|
|
499
|
+
self.pick_pos = max(self.pick_pos, 0)
|
|
500
|
+
self.pick_pos = min(self.pick_pos, self.body.row_cnt-1)
|
|
501
|
+
if self.pick_pos >= 0:
|
|
502
|
+
self.pick_pos -= (self.pick_pos % self.pick_size)
|
|
503
|
+
if self.pick_pos < 0:
|
|
504
|
+
self.scroll_pos = 0
|
|
505
|
+
elif self.scroll_pos > self.pick_pos:
|
|
506
|
+
# light position is below body bottom
|
|
507
|
+
self.scroll_pos = self.pick_pos
|
|
508
|
+
elif self.scroll_pos < self.pick_pos - (self.scroll_view_size - self.pick_size):
|
|
509
|
+
# light position is above body top
|
|
510
|
+
self.scroll_pos = self.pick_pos - (self.scroll_view_size - self.pick_size)
|
|
511
|
+
self.scroll_pos = max(self.scroll_pos, 0)
|
|
512
|
+
self.scroll_pos = min(self.scroll_pos, self.max_scroll_pos)
|
|
513
|
+
indent = 1
|
|
514
|
+
else:
|
|
515
|
+
self.scroll_pos = max(self.scroll_pos, 0)
|
|
516
|
+
self.scroll_pos = min(self.scroll_pos, self.max_scroll_pos)
|
|
517
|
+
self.pick_pos = self.scroll_pos + ind_pos - self.body_base
|
|
518
|
+
# indent = 1 if self.body.row_cnt > self.scroll_view_size else 0
|
|
519
|
+
return indent
|
|
520
|
+
|
|
521
|
+
def render_once(self):
|
|
522
|
+
"""Draw everything added."""
|
|
523
|
+
|
|
524
|
+
indent = self.fix_positions()
|
|
525
|
+
|
|
526
|
+
if indent > 0 and self.pick_mode:
|
|
527
|
+
self.scr.vline(self.body_base, 0, ' ', self.scroll_view_size)
|
|
528
|
+
if self.pick_pos >= 0:
|
|
529
|
+
pos = self.pick_pos - self.scroll_pos + self.body_base
|
|
530
|
+
self.scr.addstr(pos, 0, '>', curses.A_REVERSE)
|
|
531
|
+
|
|
532
|
+
if self.head.view_cnt < self.rows:
|
|
533
|
+
self.scr.hline(self.head.view_cnt, 0, curses.ACS_HLINE, self.cols)
|
|
534
|
+
ind_pos = self._scroll_indicator_col()
|
|
535
|
+
if ind_pos >= 0:
|
|
536
|
+
bot, cnt = ind_pos, 1
|
|
537
|
+
if 0 < ind_pos < self.cols-1:
|
|
538
|
+
width = self.scroll_view_size/self.body.row_cnt*self.cols
|
|
539
|
+
bot = max(int(round(ind_pos-width/2)), 1)
|
|
540
|
+
top = min(int(round(ind_pos+width/2)), self.cols-1)
|
|
541
|
+
cnt = top - bot
|
|
542
|
+
# self.scr.addstr(self.head.view_cnt, bot, '-'*cnt, curses.A_REVERSE)
|
|
543
|
+
# self.scr.hline(self.head.view_cnt, bot, curses.ACS_HLINE, curses.A_REVERSE, cnt)
|
|
544
|
+
for idx in range(bot, bot+cnt):
|
|
545
|
+
self.scr.addch(self.head.view_cnt, idx, curses.ACS_HLINE, curses.A_REVERSE)
|
|
546
|
+
|
|
547
|
+
self.scr.refresh()
|
|
548
|
+
|
|
549
|
+
if self.body_base < self.rows:
|
|
550
|
+
if self.pick_mode:
|
|
551
|
+
self.highlight_picked()
|
|
552
|
+
self.body.pad.refresh(self.scroll_pos, 0,
|
|
553
|
+
self.body_base, indent, self.rows-1, self.cols-1)
|
|
554
|
+
|
|
555
|
+
if self.rows > 0:
|
|
556
|
+
last_row = min(self.head.view_cnt, self.rows)-1
|
|
557
|
+
if last_row >= 0:
|
|
558
|
+
self.head.pad.refresh(0, 0, 0, indent, last_row, self.cols-1)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def answer(self, prompt='Type string [then Enter]', seed='', width=80):
|
|
563
|
+
"""
|
|
564
|
+
Popup with manual text input handling for unlimited length.
|
|
565
|
+
|
|
566
|
+
This function replaces the curses.textpad.Textbox to handle arbitrarily
|
|
567
|
+
long strings by managing a separate input buffer and display window.
|
|
568
|
+
"""
|
|
569
|
+
input_string = list(seed)
|
|
570
|
+
cursor_pos = len(input_string)
|
|
571
|
+
|
|
572
|
+
if self.rows < 3 or self.cols < 30:
|
|
573
|
+
return seed
|
|
574
|
+
|
|
575
|
+
# Define the display window properties
|
|
576
|
+
max_display_width = self.cols - 6
|
|
577
|
+
text_win_width = min(width, max_display_width)
|
|
578
|
+
|
|
579
|
+
row0, row9 = self.rows // 2 - 1, self.rows // 2 + 1
|
|
580
|
+
col0 = (self.cols - (text_win_width + 2)) // 2
|
|
581
|
+
|
|
582
|
+
while True:
|
|
583
|
+
self.scr.clear()
|
|
584
|
+
rectangle(self.scr, row0, col0, row9, col0 + text_win_width + 1)
|
|
585
|
+
self.scr.addstr(row0, col0 + 1, prompt[:text_win_width])
|
|
586
|
+
|
|
587
|
+
# Calculate the visible portion of the string
|
|
588
|
+
start_pos = max(0, cursor_pos - text_win_width + 1)
|
|
589
|
+
end_pos = start_pos + text_win_width
|
|
590
|
+
display_str = "".join(input_string[start_pos:end_pos])
|
|
591
|
+
|
|
592
|
+
self.scr.addstr(row0 + 1, col0 + 1, display_str)
|
|
593
|
+
|
|
594
|
+
# Position the cursor
|
|
595
|
+
display_cursor_pos = cursor_pos - start_pos
|
|
596
|
+
self.scr.move(row0 + 1, col0 + 1 + display_cursor_pos)
|
|
597
|
+
|
|
598
|
+
ending = 'Press ENTER to submit'[:text_win_width]
|
|
599
|
+
self.scr.addstr(row9, col0 + 1 + text_win_width - len(ending), ending)
|
|
600
|
+
self.scr.refresh()
|
|
601
|
+
curses.curs_set(2)
|
|
602
|
+
|
|
603
|
+
key = self.scr.getch()
|
|
604
|
+
|
|
605
|
+
if key in [10, 13]: # Enter key
|
|
606
|
+
return "".join(input_string)
|
|
607
|
+
elif key in [curses.KEY_BACKSPACE, 127, 8]: # Backspace
|
|
608
|
+
if cursor_pos > 0:
|
|
609
|
+
input_string.pop(cursor_pos - 1)
|
|
610
|
+
cursor_pos -= 1
|
|
611
|
+
elif key == curses.KEY_LEFT:
|
|
612
|
+
cursor_pos = max(0, cursor_pos - 1)
|
|
613
|
+
elif key == curses.KEY_RIGHT:
|
|
614
|
+
cursor_pos = min(len(input_string), cursor_pos + 1)
|
|
615
|
+
elif key == curses.KEY_DC: # Delete
|
|
616
|
+
if cursor_pos < len(input_string):
|
|
617
|
+
input_string.pop(cursor_pos)
|
|
618
|
+
elif key == curses.KEY_HOME:
|
|
619
|
+
cursor_pos = 0
|
|
620
|
+
elif key == curses.KEY_END:
|
|
621
|
+
cursor_pos = len(input_string)
|
|
622
|
+
elif 32 <= key <= 126: # Printable ASCII characters
|
|
623
|
+
input_string.insert(cursor_pos, chr(key))
|
|
624
|
+
cursor_pos += 1
|
|
625
|
+
|
|
626
|
+
def alert(self, title='ALERT', message='', height=1, width=80):
|
|
627
|
+
"""Alert box"""
|
|
628
|
+
def mod_key(key):
|
|
629
|
+
return 7 if key in (10, curses.KEY_ENTER) else key
|
|
630
|
+
|
|
631
|
+
# need 3 extra cols for rectangle (so we don't draw in southeast corner)
|
|
632
|
+
# and 3 rows (top/prompt/bottom)
|
|
633
|
+
# +Prompt--- -----------------+
|
|
634
|
+
# | First line for message... |
|
|
635
|
+
# | Last line for message. |
|
|
636
|
+
# +-----------Press ENTER to ack+
|
|
637
|
+
if self.rows < 2+height or self.cols < 30:
|
|
638
|
+
return
|
|
639
|
+
width = min(width, self.cols-3) # max text width
|
|
640
|
+
row0 = (self.rows+height-1)//2 - 1
|
|
641
|
+
row9 = row0 + height + 1
|
|
642
|
+
col0 = (self.cols - (width+2)) // 2
|
|
643
|
+
col9 = col0 + width + 2 - 1
|
|
644
|
+
|
|
645
|
+
self.scr.clear()
|
|
646
|
+
for row in range(self.rows):
|
|
647
|
+
self.scr.insstr(row, 0, ' '*self.cols, curses.A_REVERSE)
|
|
648
|
+
pad = curses.newpad(20, 200)
|
|
649
|
+
win = curses.newwin(1, 1, row9-1, col9-2) # input window
|
|
650
|
+
rectangle(self.scr, row0, col0, row9, col9)
|
|
651
|
+
self.scr.addstr(row0, col0+1, title[0:width], curses.A_REVERSE)
|
|
652
|
+
pad.addstr(message)
|
|
653
|
+
ending = 'Press ENTER to ack'[:width]
|
|
654
|
+
self.scr.addstr(row9, col0+1+width-len(ending), ending)
|
|
655
|
+
self.scr.refresh()
|
|
656
|
+
pad.refresh(0, 0, row0+1, col0+1, row9-1, col9-1)
|
|
657
|
+
Textbox(win).edit(mod_key).strip()
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
def clear(self):
|
|
661
|
+
"""Clear in prep for new screen"""
|
|
662
|
+
self.scr.clear()
|
|
663
|
+
self.head.pad.clear()
|
|
664
|
+
self.body.pad.clear()
|
|
665
|
+
self.head.texts, self.body.texts, self.last_pick_pos = [], [], -1
|
|
666
|
+
self.head.row_cnt = self.body.row_cnt = 0
|
|
667
|
+
|
|
668
|
+
def prompt(self, seconds=1.0):
|
|
669
|
+
"""Here is where we sleep waiting for commands or timeout"""
|
|
670
|
+
ctl_b, ctl_d, ctl_f, ctl_u = 2, 4, 6, 21
|
|
671
|
+
elapsed = 0.0
|
|
672
|
+
while elapsed < seconds:
|
|
673
|
+
key = self.scr.getch()
|
|
674
|
+
if key == curses.ERR:
|
|
675
|
+
elapsed += self.timeout_ms / 1000
|
|
676
|
+
continue
|
|
677
|
+
if key in (curses.KEY_RESIZE, ) or curses.is_term_resized(self.rows, self.cols):
|
|
678
|
+
# self.scr.erase()
|
|
679
|
+
self._set_screen_dims()
|
|
680
|
+
# self.render()
|
|
681
|
+
break
|
|
682
|
+
|
|
683
|
+
# App keys...
|
|
684
|
+
if key in self.handled_keys:
|
|
685
|
+
return key # return for handling
|
|
686
|
+
|
|
687
|
+
# Navigation Keys...
|
|
688
|
+
pos = self.pick_pos if self.pick_mode else self.scroll_pos
|
|
689
|
+
delta = self.pick_size if self.pick_mode else 1
|
|
690
|
+
was_pos = pos
|
|
691
|
+
if key in (ord('k'), curses.KEY_UP):
|
|
692
|
+
pos -= delta
|
|
693
|
+
elif key in (ord('j'), curses.KEY_DOWN):
|
|
694
|
+
pos += delta
|
|
695
|
+
elif key in (ctl_b, curses.KEY_PPAGE):
|
|
696
|
+
pos -= self.scroll_view_size
|
|
697
|
+
elif key in (ctl_u, ):
|
|
698
|
+
pos -= self.scroll_view_size//2
|
|
699
|
+
elif key in (ctl_f, curses.KEY_NPAGE):
|
|
700
|
+
pos += self.scroll_view_size
|
|
701
|
+
elif key in (ctl_d, ):
|
|
702
|
+
pos += self.scroll_view_size//2
|
|
703
|
+
elif key in (ord('0'), curses.KEY_HOME):
|
|
704
|
+
pos = 0
|
|
705
|
+
elif key in (ord('$'), curses.KEY_END):
|
|
706
|
+
pos = self.body.row_cnt - 1
|
|
707
|
+
elif key in (ord('H'), ):
|
|
708
|
+
pos = self.scroll_pos
|
|
709
|
+
elif key in (ord('M'), ):
|
|
710
|
+
pos = self.scroll_pos + self.scroll_view_size//2
|
|
711
|
+
elif key in (ord('L'), ):
|
|
712
|
+
pos = self.scroll_pos + self.scroll_view_size-1
|
|
713
|
+
|
|
714
|
+
if self.pick_mode:
|
|
715
|
+
self.pick_pos = pos
|
|
716
|
+
else:
|
|
717
|
+
self.scroll_pos = pos
|
|
718
|
+
self.pick_pos = pos
|
|
719
|
+
|
|
720
|
+
self.fix_positions()
|
|
721
|
+
|
|
722
|
+
if pos != was_pos:
|
|
723
|
+
self.render()
|
|
724
|
+
# ignore unhandled keys
|
|
725
|
+
return None
|
|
726
|
+
|
|
727
|
+
def no_runner():
|
|
728
|
+
"""Appease sbrun"""
|
|
729
|
+
|
|
730
|
+
if __name__ == '__main__':
|
|
731
|
+
def main():
|
|
732
|
+
"""Test program"""
|
|
733
|
+
def do_key(key):
|
|
734
|
+
nonlocal spin, win, opts
|
|
735
|
+
value = spin.do_key(key, win)
|
|
736
|
+
if key in (ord('p'), ord('s')):
|
|
737
|
+
win.set_pick_mode(on=opts.pick_mode, pick_size=opts.pick_size)
|
|
738
|
+
elif key == ord('n'):
|
|
739
|
+
win.alert(title='Info', message=f'got: {value}')
|
|
740
|
+
return value
|
|
741
|
+
|
|
742
|
+
spin = OptionSpinner()
|
|
743
|
+
spin.add_key('help_mode', '? - toggle help screen', vals=[False, True])
|
|
744
|
+
spin.add_key('pick_mode', 'p - toggle pick mode', vals=[False, True])
|
|
745
|
+
spin.add_key('pick_size', 's - #rows in pick', vals=[1, 2, 3])
|
|
746
|
+
spin.add_key('name', 'n - select name', prompt='Provide Your Name:')
|
|
747
|
+
spin.add_key('mult', 'm - row multiplier', vals=[0.5, 0.9, 1.0, 1.1, 2, 4, 16])
|
|
748
|
+
opts = spin.default_obj
|
|
749
|
+
|
|
750
|
+
win = ConsoleWindow(head_line=True, keys=spin.keys)
|
|
751
|
+
opts.name = "[hit 'n' to enter name]"
|
|
752
|
+
for loop in range(100000000000):
|
|
753
|
+
body_size = int(round(win.scroll_view_size*opts.mult))
|
|
754
|
+
if opts.help_mode:
|
|
755
|
+
win.set_pick_mode(False)
|
|
756
|
+
spin.show_help_nav_keys(win)
|
|
757
|
+
spin.show_help_body(win)
|
|
758
|
+
else:
|
|
759
|
+
win.set_pick_mode(opts.pick_mode, opts.pick_size)
|
|
760
|
+
win.add_header(f'Header: {loop} "{opts.name}"')
|
|
761
|
+
for idx, line in enumerate(range(body_size//opts.pick_size)):
|
|
762
|
+
win.add_body(f'Main pick: {loop}.{line}')
|
|
763
|
+
for num in range(1, opts.pick_size):
|
|
764
|
+
win.draw(num+idx*opts.pick_size, 0, f' addon: {loop}.{line}')
|
|
765
|
+
win.render()
|
|
766
|
+
_ = do_key(win.prompt(seconds=5))
|
|
767
|
+
win.clear()
|
|
768
|
+
|
|
769
|
+
try:
|
|
770
|
+
main()
|
|
771
|
+
except KeyboardInterrupt:
|
|
772
|
+
pass
|
|
773
|
+
except Exception as exce:
|
|
774
|
+
ConsoleWindow.stop_curses()
|
|
775
|
+
print("exception:", str(exce))
|
|
776
|
+
print(traceback.format_exc())
|
|
777
|
+
if dump_str:
|
|
778
|
+
print(dump_str)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .ConsoleWindow import ConsoleWindow, OptionSpinner
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# pyproject.toml
|
|
2
|
+
# TEST basic imports
|
|
3
|
+
# python -m venv test_venv
|
|
4
|
+
# source test_venv/bin/activate
|
|
5
|
+
# python3 -m venv test_venv
|
|
6
|
+
# source test_venv/bin/activate
|
|
7
|
+
# pip install dist/console_window-0.1.0-py3-none-any.whl
|
|
8
|
+
# python3 # within, from console_window import ConsoleWindow
|
|
9
|
+
# deactivate
|
|
10
|
+
# rm -rf test_venv/
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["flit_core >=3.2,<4"]
|
|
15
|
+
build-backend = "flit_core.buildapi"
|
|
16
|
+
|
|
17
|
+
# ----------------------------------------------------------------------
|
|
18
|
+
# FLIT MODULE CONFIGURATION
|
|
19
|
+
[tool.flit.module]
|
|
20
|
+
name = "console_window"
|
|
21
|
+
|
|
22
|
+
# ----------------------------------------------------------------------
|
|
23
|
+
# UNIFIED PROJECT METADATA (Replaces [tool.flit.metadata])
|
|
24
|
+
[project]
|
|
25
|
+
name = "console-window"
|
|
26
|
+
version = "1.0.0"
|
|
27
|
+
description = "A robust curses wrapper for creating Text User Interfaces (TUIs)."
|
|
28
|
+
readme = "README.md"
|
|
29
|
+
requires-python = ">=3.8"
|
|
30
|
+
license = {file = "LICENSE"}
|
|
31
|
+
|
|
32
|
+
dependencies = [
|
|
33
|
+
# "dependency-name >= version",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
authors = [
|
|
37
|
+
{name = "Joe Defen", email = "joedef@duck.com"},
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
classifiers = [
|
|
41
|
+
"Development Status :: 3 - Alpha",
|
|
42
|
+
"Programming Language :: Python :: 3",
|
|
43
|
+
"License :: OSI Approved :: MIT License",
|
|
44
|
+
"Operating System :: POSIX :: Linux",
|
|
45
|
+
"Topic :: Terminals",
|
|
46
|
+
"Topic :: Software Development :: User Interfaces",
|
|
47
|
+
]
|