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.
Files changed (64) hide show
  1. pyos_tui-0.1.0/LICENSE +21 -0
  2. pyos_tui-0.1.0/PKG-INFO +167 -0
  3. pyos_tui-0.1.0/README.md +138 -0
  4. pyos_tui-0.1.0/pyos/Activity.py +262 -0
  5. pyos_tui-0.1.0/pyos/Application.py +201 -0
  6. pyos_tui-0.1.0/pyos/CentralDispatch.py +126 -0
  7. pyos_tui-0.1.0/pyos/ContextUtils.py +117 -0
  8. pyos_tui-0.1.0/pyos/EventTypes.py +31 -0
  9. pyos_tui-0.1.0/pyos/KeyMap.py +51 -0
  10. pyos_tui-0.1.0/pyos/Keys.py +28 -0
  11. pyos_tui-0.1.0/pyos/__init__.py +8 -0
  12. pyos_tui-0.1.0/pyos/activities/FilePickerActivity.py +207 -0
  13. pyos_tui-0.1.0/pyos/activities/LogViewerActivity.py +145 -0
  14. pyos_tui-0.1.0/pyos/activities/ShowExceptionActivity.py +86 -0
  15. pyos_tui-0.1.0/pyos/activities/__init__.py +1 -0
  16. pyos_tui-0.1.0/pyos/db/UserPreferenceStore.py +40 -0
  17. pyos_tui-0.1.0/pyos/examples/demo.py +1122 -0
  18. pyos_tui-0.1.0/pyos/exception_utils.py +16 -0
  19. pyos_tui-0.1.0/pyos/input_handlers.py +66 -0
  20. pyos_tui-0.1.0/pyos/layout.py +213 -0
  21. pyos_tui-0.1.0/pyos/printers/Accordion.py +81 -0
  22. pyos_tui-0.1.0/pyos/printers/BottomBar.py +40 -0
  23. pyos_tui-0.1.0/pyos/printers/ContextMenuList.py +63 -0
  24. pyos_tui-0.1.0/pyos/printers/HorizontalBar.py +15 -0
  25. pyos_tui-0.1.0/pyos/printers/MultilineText.py +19 -0
  26. pyos_tui-0.1.0/pyos/printers/ScrollList.py +25 -0
  27. pyos_tui-0.1.0/pyos/printers/Spacer.py +21 -0
  28. pyos_tui-0.1.0/pyos/printers/Table.py +168 -0
  29. pyos_tui-0.1.0/pyos/printers/TextInput.py +21 -0
  30. pyos_tui-0.1.0/pyos/printers/ThreadList.py +63 -0
  31. pyos_tui-0.1.0/pyos/printers/TopBar.py +28 -0
  32. pyos_tui-0.1.0/pyos/printers/__init__.py +13 -0
  33. pyos_tui-0.1.0/pyos/printers/printers.py +317 -0
  34. pyos_tui-0.1.0/pyos/testing/__init__.py +3 -0
  35. pyos_tui-0.1.0/pyos/testing/harness.py +392 -0
  36. pyos_tui-0.1.0/pyos/testing/headful.py +187 -0
  37. pyos_tui-0.1.0/pyos_tui.egg-info/PKG-INFO +167 -0
  38. pyos_tui-0.1.0/pyos_tui.egg-info/SOURCES.txt +62 -0
  39. pyos_tui-0.1.0/pyos_tui.egg-info/dependency_links.txt +1 -0
  40. pyos_tui-0.1.0/pyos_tui.egg-info/entry_points.txt +2 -0
  41. pyos_tui-0.1.0/pyos_tui.egg-info/requires.txt +4 -0
  42. pyos_tui-0.1.0/pyos_tui.egg-info/top_level.txt +4 -0
  43. pyos_tui-0.1.0/pyproject.toml +53 -0
  44. pyos_tui-0.1.0/setup.cfg +4 -0
  45. pyos_tui-0.1.0/tests/conftest.py +33 -0
  46. pyos_tui-0.1.0/tests/harness.py +10 -0
  47. pyos_tui-0.1.0/tests/test_activity_lifecycle.py +180 -0
  48. pyos_tui-0.1.0/tests/test_application.py +245 -0
  49. pyos_tui-0.1.0/tests/test_central_dispatch.py +467 -0
  50. pyos_tui-0.1.0/tests/test_central_dispatch_extras.py +151 -0
  51. pyos_tui-0.1.0/tests/test_constraints.py +404 -0
  52. pyos_tui-0.1.0/tests/test_context_utils.py +259 -0
  53. pyos_tui-0.1.0/tests/test_demo_example.py +714 -0
  54. pyos_tui-0.1.0/tests/test_exception_utils.py +58 -0
  55. pyos_tui-0.1.0/tests/test_focus.py +165 -0
  56. pyos_tui-0.1.0/tests/test_harness.py +751 -0
  57. pyos_tui-0.1.0/tests/test_input_handlers.py +226 -0
  58. pyos_tui-0.1.0/tests/test_key_normalization.py +76 -0
  59. pyos_tui-0.1.0/tests/test_keymap.py +76 -0
  60. pyos_tui-0.1.0/tests/test_printers.py +1054 -0
  61. pyos_tui-0.1.0/tests/test_scroll_window.py +104 -0
  62. pyos_tui-0.1.0/tests/test_solve_layout.py +240 -0
  63. pyos_tui-0.1.0/tests/test_table.py +176 -0
  64. 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.
@@ -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
@@ -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)