pyos-tui 0.1.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.
- pyos_tui-0.1.0/LICENSE +21 -0
- pyos_tui-0.1.0/PKG-INFO +167 -0
- pyos_tui-0.1.0/README.md +138 -0
- pyos_tui-0.1.0/pyos/Activity.py +262 -0
- pyos_tui-0.1.0/pyos/Application.py +201 -0
- pyos_tui-0.1.0/pyos/CentralDispatch.py +126 -0
- pyos_tui-0.1.0/pyos/ContextUtils.py +117 -0
- pyos_tui-0.1.0/pyos/EventTypes.py +31 -0
- pyos_tui-0.1.0/pyos/KeyMap.py +51 -0
- pyos_tui-0.1.0/pyos/Keys.py +28 -0
- pyos_tui-0.1.0/pyos/__init__.py +8 -0
- pyos_tui-0.1.0/pyos/activities/FilePickerActivity.py +207 -0
- pyos_tui-0.1.0/pyos/activities/LogViewerActivity.py +145 -0
- pyos_tui-0.1.0/pyos/activities/ShowExceptionActivity.py +86 -0
- pyos_tui-0.1.0/pyos/activities/__init__.py +1 -0
- pyos_tui-0.1.0/pyos/db/UserPreferenceStore.py +40 -0
- pyos_tui-0.1.0/pyos/examples/demo.py +1122 -0
- pyos_tui-0.1.0/pyos/exception_utils.py +16 -0
- pyos_tui-0.1.0/pyos/input_handlers.py +66 -0
- pyos_tui-0.1.0/pyos/layout.py +213 -0
- pyos_tui-0.1.0/pyos/printers/Accordion.py +81 -0
- pyos_tui-0.1.0/pyos/printers/BottomBar.py +40 -0
- pyos_tui-0.1.0/pyos/printers/ContextMenuList.py +63 -0
- pyos_tui-0.1.0/pyos/printers/HorizontalBar.py +15 -0
- pyos_tui-0.1.0/pyos/printers/MultilineText.py +19 -0
- pyos_tui-0.1.0/pyos/printers/ScrollList.py +25 -0
- pyos_tui-0.1.0/pyos/printers/Spacer.py +21 -0
- pyos_tui-0.1.0/pyos/printers/Table.py +168 -0
- pyos_tui-0.1.0/pyos/printers/TextInput.py +21 -0
- pyos_tui-0.1.0/pyos/printers/ThreadList.py +63 -0
- pyos_tui-0.1.0/pyos/printers/TopBar.py +28 -0
- pyos_tui-0.1.0/pyos/printers/__init__.py +13 -0
- pyos_tui-0.1.0/pyos/printers/printers.py +317 -0
- pyos_tui-0.1.0/pyos/testing/__init__.py +3 -0
- pyos_tui-0.1.0/pyos/testing/harness.py +392 -0
- pyos_tui-0.1.0/pyos/testing/headful.py +187 -0
- pyos_tui-0.1.0/pyos_tui.egg-info/PKG-INFO +167 -0
- pyos_tui-0.1.0/pyos_tui.egg-info/SOURCES.txt +62 -0
- pyos_tui-0.1.0/pyos_tui.egg-info/dependency_links.txt +1 -0
- pyos_tui-0.1.0/pyos_tui.egg-info/entry_points.txt +2 -0
- pyos_tui-0.1.0/pyos_tui.egg-info/requires.txt +4 -0
- pyos_tui-0.1.0/pyos_tui.egg-info/top_level.txt +4 -0
- pyos_tui-0.1.0/pyproject.toml +53 -0
- pyos_tui-0.1.0/setup.cfg +4 -0
- pyos_tui-0.1.0/tests/conftest.py +33 -0
- pyos_tui-0.1.0/tests/harness.py +10 -0
- pyos_tui-0.1.0/tests/test_activity_lifecycle.py +180 -0
- pyos_tui-0.1.0/tests/test_application.py +245 -0
- pyos_tui-0.1.0/tests/test_central_dispatch.py +467 -0
- pyos_tui-0.1.0/tests/test_central_dispatch_extras.py +151 -0
- pyos_tui-0.1.0/tests/test_constraints.py +404 -0
- pyos_tui-0.1.0/tests/test_context_utils.py +259 -0
- pyos_tui-0.1.0/tests/test_demo_example.py +714 -0
- pyos_tui-0.1.0/tests/test_exception_utils.py +58 -0
- pyos_tui-0.1.0/tests/test_focus.py +165 -0
- pyos_tui-0.1.0/tests/test_harness.py +751 -0
- pyos_tui-0.1.0/tests/test_input_handlers.py +226 -0
- pyos_tui-0.1.0/tests/test_key_normalization.py +76 -0
- pyos_tui-0.1.0/tests/test_keymap.py +76 -0
- pyos_tui-0.1.0/tests/test_printers.py +1054 -0
- pyos_tui-0.1.0/tests/test_scroll_window.py +104 -0
- pyos_tui-0.1.0/tests/test_solve_layout.py +240 -0
- pyos_tui-0.1.0/tests/test_table.py +176 -0
- pyos_tui-0.1.0/tests/test_user_preference_store.py +45 -0
pyos_tui-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joe Pinsonault
|
|
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.
|
pyos_tui-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyos-tui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terminal-first mini-OS framework: Activities, event loop, flex layout, and TUI components.
|
|
5
|
+
Author-email: Joe Pinsonault <joe.pinsonault@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jpinsonault/pyos
|
|
8
|
+
Project-URL: Repository, https://github.com/jpinsonault/pyos
|
|
9
|
+
Project-URL: Issues, https://github.com/jpinsonault/pyos/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console :: Curses
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
21
|
+
Classifier: Topic :: Terminals
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: loguru<1,>=0.6
|
|
26
|
+
Provides-Extra: windows
|
|
27
|
+
Requires-Dist: windows-curses>=2.4.0; extra == "windows"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# pyos-tui
|
|
31
|
+
|
|
32
|
+
A terminal-first mini-OS framework for building curses apps in Python. Provides an activity stack, event loop, flex layout engine, and composable UI components — so you can focus on your app instead of wrestling with curses.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install pyos-tui
|
|
38
|
+
|
|
39
|
+
# Windows users
|
|
40
|
+
pip install pyos-tui[windows]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import curses
|
|
47
|
+
from pyos import Application, Activity
|
|
48
|
+
from pyos.EventTypes import KeyStroke
|
|
49
|
+
from pyos.Keys import ESC
|
|
50
|
+
from pyos.printers.TopBar import TopBar
|
|
51
|
+
from pyos.printers.ScrollList import ScrollList
|
|
52
|
+
from pyos.printers.BottomBar import BottomBar
|
|
53
|
+
from pyos.input_handlers import handle_scroll_list_input, ScrollChange
|
|
54
|
+
|
|
55
|
+
class HomeActivity(Activity):
|
|
56
|
+
def on_start(self):
|
|
57
|
+
self.application.subscribe(KeyStroke, self, self.on_key)
|
|
58
|
+
self.application.subscribe(ScrollChange, self, self.on_scroll)
|
|
59
|
+
|
|
60
|
+
self.display_state = {
|
|
61
|
+
"top": TopBar.display_state(items={"title": "My App", "help": "ESC quit"}),
|
|
62
|
+
"list": ScrollList.display_state(
|
|
63
|
+
screen=self.screen,
|
|
64
|
+
items=[f"Item {i}" for i in range(1, 51)],
|
|
65
|
+
selected_index=0,
|
|
66
|
+
focused=True,
|
|
67
|
+
input_handler=handle_scroll_list_input,
|
|
68
|
+
min_height=5, flex=1,
|
|
69
|
+
),
|
|
70
|
+
"bottom": BottomBar.display_state(items={"status": "Ready"}),
|
|
71
|
+
}
|
|
72
|
+
self.refresh_screen()
|
|
73
|
+
|
|
74
|
+
def on_key(self, event):
|
|
75
|
+
self.display_state["list"]["input_handler"](
|
|
76
|
+
"list", self.display_state["list"], event, self.event_queue
|
|
77
|
+
)
|
|
78
|
+
if event.key == ESC:
|
|
79
|
+
self.application.pop_activity()
|
|
80
|
+
self.refresh_screen()
|
|
81
|
+
|
|
82
|
+
def on_scroll(self, event):
|
|
83
|
+
idx = self.display_state["list"]["selected_index"]
|
|
84
|
+
self.display_state["bottom"]["items"]["status"] = f"Selected: {idx}"
|
|
85
|
+
self.refresh_screen()
|
|
86
|
+
|
|
87
|
+
def main(stdscr):
|
|
88
|
+
app = Application(stdscr)
|
|
89
|
+
app.start(HomeActivity())
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
curses.wrapper(main)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Features
|
|
96
|
+
|
|
97
|
+
**Activity stack** — Push, pop, and replace screens like a mobile navigation controller. Each Activity owns its UI state and subscriptions; the framework cleans up automatically on pop.
|
|
98
|
+
|
|
99
|
+
**Event system** — Subscribe to typed events (`KeyStroke`, `TextBoxSubmit`, `ScrollChange`, etc.). Activities subscribe in `on_start` and the framework unsubscribes everything on stop.
|
|
100
|
+
|
|
101
|
+
**Flex layout** — Regions can be fixed-height, flex (proportional with min/max), or auto-measured. The layout solver distributes terminal rows automatically.
|
|
102
|
+
|
|
103
|
+
**Composable UI components** — Built-in printers for common patterns:
|
|
104
|
+
|
|
105
|
+
| Component | Description |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `TopBar` / `BottomBar` | Status bars |
|
|
108
|
+
| `ScrollList` | Scrollable list with selection |
|
|
109
|
+
| `TextInput` | Single-line text field with cursor |
|
|
110
|
+
| `Table` | Auto-sized columns with headers |
|
|
111
|
+
| `ContextMenuList` | List items with inline action menus |
|
|
112
|
+
| `MultilineText` | Read-only text block |
|
|
113
|
+
| `Accordion` | Collapsible sections |
|
|
114
|
+
| `Spacer` | Fills remaining space |
|
|
115
|
+
|
|
116
|
+
**Input handlers** — Plug-in handlers for text fields (`handle_text_box_input`) and scroll lists (`handle_scroll_list_input`) that manage cursor, selection, and emit events.
|
|
117
|
+
|
|
118
|
+
**Threading** — `CentralDispatch` provides serial and concurrent dispatch queues. The main queue is safe for UI mutations; background work marshals updates back via `main_thread.submit_async(...)`.
|
|
119
|
+
|
|
120
|
+
**Error recovery** — Unhandled exceptions push a traceback viewer. Press ESC to attempt stack recovery without crashing.
|
|
121
|
+
|
|
122
|
+
**Built-in log viewer** — Press F1 to tail `application.log` in a dedicated activity.
|
|
123
|
+
|
|
124
|
+
**Pytest plugin** — Ships a headful test renderer (`pyos-headful`) as a pytest plugin for watching your TUI tests render in real time.
|
|
125
|
+
|
|
126
|
+
## Activity lifecycle
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
_start(application) → on_start() → refresh_screen() → _stop()
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Override `on_start()` to set up `display_state` and event subscriptions. Override `on_stop()` for cleanup. Navigate with:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
self.application.segue_to(NextActivity()) # push
|
|
136
|
+
self.application.segue_to(NextActivity(), Segue.REPLACE) # replace
|
|
137
|
+
self.application.pop_activity() # pop (empty stack stops the app)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Layout
|
|
141
|
+
|
|
142
|
+
Each entry in `display_state` is a dict with a `"layout"` key:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
{"height": 3} # fixed: exactly 3 rows
|
|
146
|
+
{"flex": 1, "min_height": 5} # flex: share remaining space
|
|
147
|
+
{"flex": 2, "min_height": 3, "max_height": 20} # flex with bounds
|
|
148
|
+
# omit both → auto-measured from line_generator output
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Threading
|
|
152
|
+
|
|
153
|
+
Only read/write `display_state` on the main thread. From background work:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
self.main_thread.submit_async(self.refresh_screen)
|
|
157
|
+
self.main_thread.submit_async(self.on_new_data, payload)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Requirements
|
|
161
|
+
|
|
162
|
+
- Python 3.9+
|
|
163
|
+
- A terminal that supports curses (most Unix terminals; Windows via `windows-curses`)
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
MIT
|
pyos_tui-0.1.0/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# pyos-tui
|
|
2
|
+
|
|
3
|
+
A terminal-first mini-OS framework for building curses apps in Python. Provides an activity stack, event loop, flex layout engine, and composable UI components — so you can focus on your app instead of wrestling with curses.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install pyos-tui
|
|
9
|
+
|
|
10
|
+
# Windows users
|
|
11
|
+
pip install pyos-tui[windows]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import curses
|
|
18
|
+
from pyos import Application, Activity
|
|
19
|
+
from pyos.EventTypes import KeyStroke
|
|
20
|
+
from pyos.Keys import ESC
|
|
21
|
+
from pyos.printers.TopBar import TopBar
|
|
22
|
+
from pyos.printers.ScrollList import ScrollList
|
|
23
|
+
from pyos.printers.BottomBar import BottomBar
|
|
24
|
+
from pyos.input_handlers import handle_scroll_list_input, ScrollChange
|
|
25
|
+
|
|
26
|
+
class HomeActivity(Activity):
|
|
27
|
+
def on_start(self):
|
|
28
|
+
self.application.subscribe(KeyStroke, self, self.on_key)
|
|
29
|
+
self.application.subscribe(ScrollChange, self, self.on_scroll)
|
|
30
|
+
|
|
31
|
+
self.display_state = {
|
|
32
|
+
"top": TopBar.display_state(items={"title": "My App", "help": "ESC quit"}),
|
|
33
|
+
"list": ScrollList.display_state(
|
|
34
|
+
screen=self.screen,
|
|
35
|
+
items=[f"Item {i}" for i in range(1, 51)],
|
|
36
|
+
selected_index=0,
|
|
37
|
+
focused=True,
|
|
38
|
+
input_handler=handle_scroll_list_input,
|
|
39
|
+
min_height=5, flex=1,
|
|
40
|
+
),
|
|
41
|
+
"bottom": BottomBar.display_state(items={"status": "Ready"}),
|
|
42
|
+
}
|
|
43
|
+
self.refresh_screen()
|
|
44
|
+
|
|
45
|
+
def on_key(self, event):
|
|
46
|
+
self.display_state["list"]["input_handler"](
|
|
47
|
+
"list", self.display_state["list"], event, self.event_queue
|
|
48
|
+
)
|
|
49
|
+
if event.key == ESC:
|
|
50
|
+
self.application.pop_activity()
|
|
51
|
+
self.refresh_screen()
|
|
52
|
+
|
|
53
|
+
def on_scroll(self, event):
|
|
54
|
+
idx = self.display_state["list"]["selected_index"]
|
|
55
|
+
self.display_state["bottom"]["items"]["status"] = f"Selected: {idx}"
|
|
56
|
+
self.refresh_screen()
|
|
57
|
+
|
|
58
|
+
def main(stdscr):
|
|
59
|
+
app = Application(stdscr)
|
|
60
|
+
app.start(HomeActivity())
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
curses.wrapper(main)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Features
|
|
67
|
+
|
|
68
|
+
**Activity stack** — Push, pop, and replace screens like a mobile navigation controller. Each Activity owns its UI state and subscriptions; the framework cleans up automatically on pop.
|
|
69
|
+
|
|
70
|
+
**Event system** — Subscribe to typed events (`KeyStroke`, `TextBoxSubmit`, `ScrollChange`, etc.). Activities subscribe in `on_start` and the framework unsubscribes everything on stop.
|
|
71
|
+
|
|
72
|
+
**Flex layout** — Regions can be fixed-height, flex (proportional with min/max), or auto-measured. The layout solver distributes terminal rows automatically.
|
|
73
|
+
|
|
74
|
+
**Composable UI components** — Built-in printers for common patterns:
|
|
75
|
+
|
|
76
|
+
| Component | Description |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `TopBar` / `BottomBar` | Status bars |
|
|
79
|
+
| `ScrollList` | Scrollable list with selection |
|
|
80
|
+
| `TextInput` | Single-line text field with cursor |
|
|
81
|
+
| `Table` | Auto-sized columns with headers |
|
|
82
|
+
| `ContextMenuList` | List items with inline action menus |
|
|
83
|
+
| `MultilineText` | Read-only text block |
|
|
84
|
+
| `Accordion` | Collapsible sections |
|
|
85
|
+
| `Spacer` | Fills remaining space |
|
|
86
|
+
|
|
87
|
+
**Input handlers** — Plug-in handlers for text fields (`handle_text_box_input`) and scroll lists (`handle_scroll_list_input`) that manage cursor, selection, and emit events.
|
|
88
|
+
|
|
89
|
+
**Threading** — `CentralDispatch` provides serial and concurrent dispatch queues. The main queue is safe for UI mutations; background work marshals updates back via `main_thread.submit_async(...)`.
|
|
90
|
+
|
|
91
|
+
**Error recovery** — Unhandled exceptions push a traceback viewer. Press ESC to attempt stack recovery without crashing.
|
|
92
|
+
|
|
93
|
+
**Built-in log viewer** — Press F1 to tail `application.log` in a dedicated activity.
|
|
94
|
+
|
|
95
|
+
**Pytest plugin** — Ships a headful test renderer (`pyos-headful`) as a pytest plugin for watching your TUI tests render in real time.
|
|
96
|
+
|
|
97
|
+
## Activity lifecycle
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
_start(application) → on_start() → refresh_screen() → _stop()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Override `on_start()` to set up `display_state` and event subscriptions. Override `on_stop()` for cleanup. Navigate with:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
self.application.segue_to(NextActivity()) # push
|
|
107
|
+
self.application.segue_to(NextActivity(), Segue.REPLACE) # replace
|
|
108
|
+
self.application.pop_activity() # pop (empty stack stops the app)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Layout
|
|
112
|
+
|
|
113
|
+
Each entry in `display_state` is a dict with a `"layout"` key:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
{"height": 3} # fixed: exactly 3 rows
|
|
117
|
+
{"flex": 1, "min_height": 5} # flex: share remaining space
|
|
118
|
+
{"flex": 2, "min_height": 3, "max_height": 20} # flex with bounds
|
|
119
|
+
# omit both → auto-measured from line_generator output
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Threading
|
|
123
|
+
|
|
124
|
+
Only read/write `display_state` on the main thread. From background work:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
self.main_thread.submit_async(self.refresh_screen)
|
|
128
|
+
self.main_thread.submit_async(self.on_new_data, payload)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Requirements
|
|
132
|
+
|
|
133
|
+
- Python 3.9+
|
|
134
|
+
- A terminal that supports curses (most Unix terminals; Windows via `windows-curses`)
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import json
|
|
3
|
+
from queue import Queue
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from .CentralDispatch import SerialDispatchQueue
|
|
9
|
+
from .ContextUtils import get_fixed_size
|
|
10
|
+
from .exception_utils import try_make_lines, try_print_line
|
|
11
|
+
from pprint import pprint
|
|
12
|
+
|
|
13
|
+
def solve_layout(display_state: dict, screen_height: int) -> dict:
|
|
14
|
+
allocated = {}
|
|
15
|
+
remaining = int(screen_height)
|
|
16
|
+
if remaining < 0:
|
|
17
|
+
remaining = 0
|
|
18
|
+
|
|
19
|
+
fixed_total = 0
|
|
20
|
+
flex_items = []
|
|
21
|
+
|
|
22
|
+
for key, context in display_state.items():
|
|
23
|
+
layout = context.get("layout", {})
|
|
24
|
+
if "height" in layout:
|
|
25
|
+
h = int(layout["height"])
|
|
26
|
+
h = max(0, h)
|
|
27
|
+
allocated[key] = h
|
|
28
|
+
fixed_total += h
|
|
29
|
+
else:
|
|
30
|
+
flex_value = float(layout.get("flex", 0) or 0)
|
|
31
|
+
if flex_value > 0:
|
|
32
|
+
min_h = int(layout.get("min_height", 0) or 0)
|
|
33
|
+
max_h = layout.get("max_height", None)
|
|
34
|
+
if max_h is None:
|
|
35
|
+
max_h = max(0, screen_height) # large cap so flex distribution is not artificially clamped
|
|
36
|
+
else:
|
|
37
|
+
max_h = int(max_h)
|
|
38
|
+
if max_h < 0:
|
|
39
|
+
max_h = 0
|
|
40
|
+
curr = max(0, min_h)
|
|
41
|
+
cap = max(0, max_h - curr)
|
|
42
|
+
flex_items.append({
|
|
43
|
+
"key": key,
|
|
44
|
+
"flex": float(flex_value),
|
|
45
|
+
"curr": int(curr),
|
|
46
|
+
"cap": int(cap),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
remaining = max(0, remaining - fixed_total)
|
|
50
|
+
|
|
51
|
+
if not flex_items:
|
|
52
|
+
return allocated
|
|
53
|
+
|
|
54
|
+
sum_min = sum(it["curr"] for it in flex_items)
|
|
55
|
+
|
|
56
|
+
if remaining <= sum_min:
|
|
57
|
+
for it in flex_items:
|
|
58
|
+
allocated[it["key"]] = int(it["curr"])
|
|
59
|
+
return allocated
|
|
60
|
+
|
|
61
|
+
remaining -= sum_min
|
|
62
|
+
|
|
63
|
+
active = [it for it in flex_items if it["cap"] > 0 and it["flex"] > 0.0]
|
|
64
|
+
|
|
65
|
+
while remaining > 0 and active:
|
|
66
|
+
total_flex = sum(it["flex"] for it in active)
|
|
67
|
+
if total_flex <= 0:
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
adds = []
|
|
71
|
+
frac_order = []
|
|
72
|
+
gained = 0
|
|
73
|
+
|
|
74
|
+
for it in active:
|
|
75
|
+
share = (it["flex"] / total_flex) * remaining
|
|
76
|
+
base = int(share)
|
|
77
|
+
add = min(it["cap"], base)
|
|
78
|
+
adds.append(add)
|
|
79
|
+
frac_order.append(share - base)
|
|
80
|
+
|
|
81
|
+
for it, add in zip(active, adds):
|
|
82
|
+
if add > 0:
|
|
83
|
+
it["curr"] += add
|
|
84
|
+
it["cap"] -= add
|
|
85
|
+
remaining -= add
|
|
86
|
+
gained += add
|
|
87
|
+
|
|
88
|
+
if remaining > 0:
|
|
89
|
+
if gained == 0:
|
|
90
|
+
order = sorted(range(len(active)), key=lambda i: frac_order[i], reverse=True)
|
|
91
|
+
for idx in order:
|
|
92
|
+
if remaining <= 0:
|
|
93
|
+
break
|
|
94
|
+
it = active[idx]
|
|
95
|
+
if it["cap"] > 0:
|
|
96
|
+
it["curr"] += 1
|
|
97
|
+
it["cap"] -= 1
|
|
98
|
+
remaining -= 1
|
|
99
|
+
gained += 1
|
|
100
|
+
if remaining > 0 and gained == 0:
|
|
101
|
+
for it in active:
|
|
102
|
+
if remaining <= 0:
|
|
103
|
+
break
|
|
104
|
+
if it["cap"] > 0:
|
|
105
|
+
take = min(it["cap"], remaining)
|
|
106
|
+
it["curr"] += take
|
|
107
|
+
it["cap"] -= take
|
|
108
|
+
remaining -= take
|
|
109
|
+
gained += take
|
|
110
|
+
|
|
111
|
+
active = [it for it in active if it["cap"] > 0 and it["flex"] > 0.0]
|
|
112
|
+
|
|
113
|
+
for it in flex_items:
|
|
114
|
+
allocated[it["key"]] = int(max(0, it["curr"]))
|
|
115
|
+
|
|
116
|
+
return allocated
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Activity:
|
|
120
|
+
def __init__(self):
|
|
121
|
+
self.application = None
|
|
122
|
+
self.event_queue: Queue = None
|
|
123
|
+
self.screen = None
|
|
124
|
+
self.main_thread: SerialDispatchQueue = None
|
|
125
|
+
self.display_state = {}
|
|
126
|
+
self.previous_display_state = {}
|
|
127
|
+
self.lifecycle_state = "init"
|
|
128
|
+
self.tab_order: list = []
|
|
129
|
+
self.focus: str = None
|
|
130
|
+
|
|
131
|
+
def _set_focus(self, target):
|
|
132
|
+
"""Move focus to *target*, updating ``focused`` flags in display_state."""
|
|
133
|
+
if self.focus and self.focus in self.display_state:
|
|
134
|
+
self.display_state[self.focus]["focused"] = False
|
|
135
|
+
self.focus = target
|
|
136
|
+
if target and target in self.display_state:
|
|
137
|
+
self.display_state[target]["focused"] = True
|
|
138
|
+
|
|
139
|
+
def cycle_focus(self):
|
|
140
|
+
"""Advance focus to the next entry in ``tab_order``, wrapping around."""
|
|
141
|
+
if not self.tab_order:
|
|
142
|
+
return
|
|
143
|
+
if self.focus in self.tab_order:
|
|
144
|
+
idx = (self.tab_order.index(self.focus) + 1) % len(self.tab_order)
|
|
145
|
+
else:
|
|
146
|
+
idx = 0
|
|
147
|
+
self._set_focus(self.tab_order[idx])
|
|
148
|
+
|
|
149
|
+
def delegate_to_focused(self, event):
|
|
150
|
+
"""Route *event* to the ``input_handler`` of the focused context.
|
|
151
|
+
|
|
152
|
+
Returns True if a handler was found and called, False otherwise.
|
|
153
|
+
"""
|
|
154
|
+
if not self.focus or self.focus not in self.display_state:
|
|
155
|
+
return False
|
|
156
|
+
ctx = self.display_state[self.focus]
|
|
157
|
+
handler = ctx.get("input_handler")
|
|
158
|
+
if handler:
|
|
159
|
+
handler(self.focus, ctx, event, self.event_queue)
|
|
160
|
+
return True
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
def _start(self, application):
|
|
164
|
+
self.lifecycle_state = "starting"
|
|
165
|
+
self.application = application
|
|
166
|
+
self.event_queue = application.event_queue
|
|
167
|
+
self.screen = application.curses_screen
|
|
168
|
+
self.main_thread = application.main_thread
|
|
169
|
+
self.background_thread = application.background_thread
|
|
170
|
+
self.on_start()
|
|
171
|
+
self.lifecycle_state = "started"
|
|
172
|
+
self.refresh_screen()
|
|
173
|
+
|
|
174
|
+
def on_start(self): pass
|
|
175
|
+
|
|
176
|
+
def _stop(self):
|
|
177
|
+
self.on_stop()
|
|
178
|
+
self.lifecycle_state = "stopped"
|
|
179
|
+
self.application = None
|
|
180
|
+
|
|
181
|
+
def on_stop(self): pass
|
|
182
|
+
|
|
183
|
+
def generate_line_printers(self) -> list[Callable]:
|
|
184
|
+
from .layout import Constraint, solve_constraints
|
|
185
|
+
|
|
186
|
+
num_rows, num_cols = self.application.curses_screen.getmaxyx()
|
|
187
|
+
|
|
188
|
+
for key, context in self.display_state.items():
|
|
189
|
+
if "layout" not in context:
|
|
190
|
+
raise Exception(f"Legacy display_state item detected without 'layout': {key}")
|
|
191
|
+
|
|
192
|
+
constraint_keys = {k for k, ctx in self.display_state.items() if isinstance(ctx.get("layout"), Constraint)}
|
|
193
|
+
dict_keys = {k for k, ctx in self.display_state.items() if isinstance(ctx.get("layout"), dict)}
|
|
194
|
+
|
|
195
|
+
if constraint_keys and dict_keys:
|
|
196
|
+
raise TypeError(
|
|
197
|
+
f"Cannot mix Constraint layouts with dict layouts in the same display_state. "
|
|
198
|
+
f"Constraint keys: {sorted(constraint_keys)}, dict keys: {sorted(dict_keys)}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if constraint_keys:
|
|
202
|
+
heights = solve_constraints(self.display_state, num_rows)
|
|
203
|
+
else:
|
|
204
|
+
measured_state = {}
|
|
205
|
+
for key, context in self.display_state.items():
|
|
206
|
+
layout = dict(context.get("layout", {}))
|
|
207
|
+
flex_value = float(layout.get("flex", 0) or 0)
|
|
208
|
+
is_fixed = "height" in layout
|
|
209
|
+
is_flex = (flex_value > 0)
|
|
210
|
+
if not is_fixed and not is_flex:
|
|
211
|
+
min_h = int(layout.get("min_height", 0) or 0)
|
|
212
|
+
max_h_raw = layout.get("max_height", None)
|
|
213
|
+
try:
|
|
214
|
+
printers = try_make_lines(context, 1_000_000)
|
|
215
|
+
natural = len(list(printers))
|
|
216
|
+
except Exception:
|
|
217
|
+
natural = min_h
|
|
218
|
+
if max_h_raw is None:
|
|
219
|
+
natural_h = max(min_h, natural)
|
|
220
|
+
else:
|
|
221
|
+
max_h = int(max_h_raw)
|
|
222
|
+
if max_h < 0:
|
|
223
|
+
max_h = 0
|
|
224
|
+
natural_h = max(min_h, min(max_h, natural))
|
|
225
|
+
ml = layout.copy()
|
|
226
|
+
ml["height"] = int(max(0, natural_h))
|
|
227
|
+
ml.pop("flex", None)
|
|
228
|
+
measured_state[key] = {"layout": ml}
|
|
229
|
+
else:
|
|
230
|
+
measured_state[key] = {"layout": layout}
|
|
231
|
+
|
|
232
|
+
heights = solve_layout(measured_state, num_rows)
|
|
233
|
+
|
|
234
|
+
next_y_index = 0
|
|
235
|
+
screen_line_printers = []
|
|
236
|
+
for key, context in self.display_state.items():
|
|
237
|
+
allocated = int(heights.get(key, 0))
|
|
238
|
+
if allocated > 0:
|
|
239
|
+
line_printers = try_make_lines(context, allocated)
|
|
240
|
+
line_printers = list(line_printers)[:allocated]
|
|
241
|
+
screen_line_printers += line_printers
|
|
242
|
+
next_y_index += len(line_printers)
|
|
243
|
+
if next_y_index >= num_rows:
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
return screen_line_printers
|
|
247
|
+
|
|
248
|
+
def refresh_screen(self):
|
|
249
|
+
if self.lifecycle_state != "stopped":
|
|
250
|
+
screen = self.application.curses_screen
|
|
251
|
+
screen_line_printers = self.generate_line_printers()
|
|
252
|
+
|
|
253
|
+
screen.clear()
|
|
254
|
+
for y, line_printer in enumerate(screen_line_printers):
|
|
255
|
+
try_print_line(line_printer, screen, y)
|
|
256
|
+
|
|
257
|
+
screen.refresh()
|
|
258
|
+
|
|
259
|
+
self.previous_display_state = self.display_state
|
|
260
|
+
|
|
261
|
+
def async_refresh_screen(self):
|
|
262
|
+
self.main_thread.submit_async(self.refresh_screen)
|