ppui 2026.1.20.2__py3-none-any.whl

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.
ppui/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ from .core import (
2
+ console,
3
+ prompt_yes_no,
4
+ copy_to_clipboard,
5
+ prompt_toolkit_menu,
6
+ format_menu_choices,
7
+ Menu
8
+ )
9
+ from .base import (
10
+ UIElement,
11
+ Selection,
12
+ Option,
13
+ Presentable
14
+ )
15
+
16
+ __all__ = [
17
+ "console",
18
+ "prompt_yes_no",
19
+ "copy_to_clipboard",
20
+ "prompt_toolkit_menu",
21
+ "format_menu_choices",
22
+ "Menu",
23
+ "UIElement",
24
+ "Selection",
25
+ "Option",
26
+ "Presentable"
27
+ ]
ppui/base.py ADDED
@@ -0,0 +1,47 @@
1
+ """
2
+ Base abstractions for the PPUI (Python Process User Intent) system.
3
+ """
4
+ from abc import ABC, abstractmethod
5
+ from typing import Any, List, Optional, Callable, Dict, Union
6
+
7
+ class Presentable(ABC):
8
+ """Base class for anything that can be presented to a user (TUI, Web, Voice, etc.)"""
9
+ @abstractmethod
10
+ def present(self) -> Any:
11
+ """Present the element to the user and return the result if applicable."""
12
+ pass
13
+
14
+ class UIElement(Presentable):
15
+ """A generic UI element."""
16
+ def __init__(self, title: str = "", style: str = ""):
17
+ self.title = title
18
+ self.style = style
19
+
20
+ class Option:
21
+ """A single choice within a Selection."""
22
+ def __init__(self, label: str, value: Any = None, callback: Optional[Callable] = None, description: str = ""):
23
+ self.label = label
24
+ self.value = value if value is not None else label
25
+ self.callback = callback
26
+ self.description = description
27
+
28
+ class Selection(UIElement):
29
+ """Abstract concept of providing options for a user to choose from."""
30
+ def __init__(self, title: str = "", style: str = ""):
31
+ super().__init__(title, style)
32
+ self.options: List[Union[Option, 'Selection']] = []
33
+
34
+ @abstractmethod
35
+ def add_option(self, label: str, value_or_callback: Any = None, description: str = ""):
36
+ """Add a single option to the selection."""
37
+ pass
38
+
39
+ @abstractmethod
40
+ def add_submenu(self, label: str, submenu: 'Selection', behavior: str = "push"):
41
+ """Add a nested selection (submenu). behavior can be 'push' or 'inline'."""
42
+ pass
43
+
44
+ @abstractmethod
45
+ def run(self, loop: bool = True) -> Any:
46
+ """Start the interaction loop."""
47
+ pass
ppui/core.py ADDED
@@ -0,0 +1,316 @@
1
+ import shutil
2
+ import subprocess
3
+ import sys
4
+ import uuid
5
+ from typing import Optional, Dict, Any, List, Callable, Union
6
+
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from .base import Selection, Option, UIElement
10
+
11
+ # Shared console instance for all Rich output
12
+ console = Console()
13
+
14
+ class Menu(Selection):
15
+ """
16
+ TUI implementation of the abstract Selection concept.
17
+ Encapsulates state management, boilerplate loops, and UI logic.
18
+ """
19
+ def __init__(self, title: str, style: str = "bold blue"):
20
+ super().__init__(title, style)
21
+ self.callbacks: Dict[str, Callable] = {}
22
+ self._items: List[Dict[str, Any]] = []
23
+
24
+ def add_option(self, label: str, value_or_callback: Any = None, description: str = ""):
25
+ """
26
+ Adds a menu option.
27
+ If a callable is passed, it's stored as a callback and executed automatically in run().
28
+ If a string/value is passed, run() returns it upon selection.
29
+ """
30
+ if callable(value_or_callback):
31
+ action_id = f"cb_{uuid.uuid4().hex[:8]}"
32
+ self.callbacks[action_id] = value_or_callback
33
+ self._items.append({"title": label, "value": action_id, "description": description})
34
+ else:
35
+ self._items.append({"title": label, "value": value_or_callback, "description": description})
36
+
37
+ def add_item(self, label: str, action_or_callback: Any):
38
+ """Backward compatibility alias for add_option."""
39
+ self.add_option(label, action_or_callback)
40
+
41
+ def add_submenu(self, label: str, submenu: Selection, behavior: str = "push"):
42
+ """
43
+ Adds a nested selection.
44
+ 'push': Standard drill-down (opens a new menu).
45
+ 'inline': Flattens submenu options into the current menu with indentation.
46
+ """
47
+ if behavior == "push":
48
+ self.add_option(label, lambda: submenu.run())
49
+ elif behavior == "inline":
50
+ # For inline, we store a reference to the submenu object
51
+ # and handle it during formatting in the run loop.
52
+ self._items.append({
53
+ "title": label,
54
+ "value": submenu,
55
+ "behavior": "inline",
56
+ "is_submenu": True,
57
+ "expanded": False
58
+ })
59
+
60
+ def add_back_item(self, label: str = "[Back]"):
61
+ """Convenience method to add a standard Back option."""
62
+ self.add_option(label, "back")
63
+
64
+ def add_quit_item(self, label: str = "[Quit]"):
65
+ """Convenience method to add a standard Quit option."""
66
+ self.add_option(label, "quit")
67
+
68
+ def _get_flattened_items(self) -> List[Dict[str, Any]]:
69
+ """Flatten inline submenus if they are expanded."""
70
+ flat_list = []
71
+ for item in self._items:
72
+ flat_list.append(item)
73
+ if item.get("is_submenu") and item.get("behavior") == "inline" and item.get("expanded"):
74
+ submenu = item["value"]
75
+ # Recursively get items from submenu
76
+ sub_items = submenu._get_flattened_items() if isinstance(submenu, Menu) else []
77
+ for sub_item in sub_items:
78
+ indented_item = sub_item.copy()
79
+ indented_item["title"] = f" {sub_item['title']}"
80
+ flat_list.append(indented_item)
81
+ return flat_list
82
+
83
+ def _get_callback(self, action_id: str) -> Optional[Callable]:
84
+ """Resolve a callback ID, searching through inline submenus if necessary."""
85
+ if action_id in self.callbacks:
86
+ return self.callbacks[action_id]
87
+
88
+ # Search in inline submenus
89
+ for item in self._items:
90
+ if item.get("is_submenu") and item.get("behavior") == "inline":
91
+ submenu = item["value"]
92
+ if isinstance(submenu, Menu):
93
+ cb = submenu._get_callback(action_id)
94
+ if cb:
95
+ return cb
96
+ return None
97
+
98
+ def run(self, loop: bool = True) -> Any:
99
+ """
100
+ Starts the interactive menu interaction.
101
+ Returns the selected value (or 'quit'/'back' for standard items).
102
+ """
103
+ while True:
104
+ console.print(Panel(self.title, style=self.style))
105
+
106
+ # Flatten items for display (handles inline expansion)
107
+ display_items = self._get_flattened_items()
108
+
109
+ choices = format_menu_choices(display_items, title_field="title", value_field="value")
110
+ selection = prompt_toolkit_menu(choices)
111
+
112
+ if selection is None: # Esc or Ctrl-C
113
+ return "quit"
114
+
115
+ # Handle inline submenu toggle
116
+ if isinstance(selection, Menu):
117
+ for item in self._items:
118
+ if item.get("value") == selection:
119
+ item["expanded"] = not item.get("expanded")
120
+ break
121
+ continue
122
+
123
+ # Execute callback if it exists (resolving via hierarchy)
124
+ callback = self._get_callback(str(selection))
125
+ if callback:
126
+ res = callback()
127
+ if not loop:
128
+ return selection # Return the ID that triggered the callback
129
+ continue
130
+
131
+ # Standard navigation handling
132
+ if selection in ["quit", "exit"]:
133
+ return "quit"
134
+ if selection == "back":
135
+ return "back"
136
+
137
+ if not loop:
138
+ return selection
139
+
140
+ def present(self) -> Any:
141
+ """Presentable implementation."""
142
+ return self.run()
143
+
144
+ def prompt_yes_no(message: str, default: bool = False) -> bool:
145
+ """Standardize yes/no confirmation prompts."""
146
+ suffix = "[Y/n]" if default else "[y/N]"
147
+ while True:
148
+ choice = input(f"{message} {suffix}: ").strip().lower()
149
+ if choice == "":
150
+ return default
151
+ if choice in {"y", "yes"}:
152
+ return True
153
+ if choice in {"n", "no"}:
154
+ return False
155
+ console.print("[yellow]Please enter y or n.[/yellow]")
156
+
157
+ def copy_to_clipboard(text: str) -> bool:
158
+ """Copy text to the system clipboard across different platforms."""
159
+ try:
160
+ if shutil.which("pbcopy"):
161
+ subprocess.run(["pbcopy"], input=text, text=True, check=True)
162
+ return True
163
+ elif shutil.which("xclip"):
164
+ subprocess.run(["xclip", "-selection", "clipboard"], input=text, text=True, check=True)
165
+ return True
166
+ elif shutil.which("xsel"):
167
+ subprocess.run(["xsel", "--clipboard", "--input"], input=text, text=True, check=True)
168
+ return True
169
+ except Exception:
170
+ pass
171
+ return False
172
+
173
+ def prompt_toolkit_menu(choices, style=None, hotkeys=None, default_idx=0):
174
+ """Interactive selection menu supporting arrow keys and immediate hotkeys."""
175
+ from prompt_toolkit.key_binding import KeyBindings
176
+ from prompt_toolkit.application import Application
177
+ from prompt_toolkit.layout import Layout
178
+ from prompt_toolkit.layout.containers import Window, HSplit
179
+ from prompt_toolkit.layout.controls import FormattedTextControl
180
+
181
+ idx = max(0, min(default_idx, len(choices) - 1)) if choices else 0
182
+ kb = KeyBindings()
183
+
184
+ @kb.add('up')
185
+ def _(event):
186
+ nonlocal idx
187
+ idx = (idx - 1) % len(choices)
188
+
189
+ @kb.add('down')
190
+ def _(event):
191
+ nonlocal idx
192
+ idx = (idx + 1) % len(choices)
193
+
194
+ @kb.add('enter')
195
+ def _(event):
196
+ event.app.exit(result=choices[idx].value)
197
+
198
+ @kb.add('escape')
199
+ @kb.add('c-c')
200
+ def _(event):
201
+ event.app.exit(result=None)
202
+
203
+ def make_hotkey_handler(h_val):
204
+ keys = list(str(h_val))
205
+ @kb.add(*keys)
206
+ def _(event):
207
+ for choice in choices:
208
+ clean_title = choice.title.strip().lower()
209
+ title_prefix = clean_title.split('.')[0].strip().lstrip('0')
210
+ h_prefix = str(h_val).lstrip('0')
211
+
212
+ if clean_title.startswith(f"{h_val}."):
213
+ event.app.exit(result=choice.value)
214
+ return
215
+ if title_prefix == h_prefix and title_prefix != "":
216
+ event.app.exit(result=choice.value)
217
+ return
218
+ if str(choice.value) == str(h_val):
219
+ event.app.exit(result=choice.value)
220
+ return
221
+ return _
222
+
223
+ if not hotkeys:
224
+ hotkeys = []
225
+ for choice in choices:
226
+ title = choice.title.strip()
227
+ if '.' in title:
228
+ prefix = title.split('.')[0].strip().lower()
229
+ if prefix:
230
+ hotkeys.append(prefix)
231
+ if prefix.isdigit() and prefix.startswith('0') and prefix != '0':
232
+ hotkeys.append(prefix.lstrip('0'))
233
+ if not hotkeys:
234
+ hotkeys = [str(i) for i in range(1, 10)] + ['q', 'b']
235
+
236
+ seen = set()
237
+ unique_hotkeys = []
238
+ for h in hotkeys:
239
+ if h not in seen:
240
+ unique_hotkeys.append(h)
241
+ seen.add(h)
242
+
243
+ for h in unique_hotkeys:
244
+ make_hotkey_handler(h)
245
+
246
+ def get_text():
247
+ result = []
248
+ for i, choice in enumerate(choices):
249
+ if i == idx:
250
+ result.append(('class:selected', f" » {choice.title}\n"))
251
+ else:
252
+ result.append(('', f" {choice.title}\n"))
253
+ return result
254
+
255
+ layout = Layout(HSplit([
256
+ Window(content=FormattedTextControl(get_text)),
257
+ ]))
258
+
259
+ from prompt_toolkit.styles import Style
260
+ if not style:
261
+ style = Style([('selected', 'fg:#cc9900')])
262
+
263
+ app = Application(layout=layout, key_bindings=kb, style=style, full_screen=False)
264
+ return app.run()
265
+
266
+ def format_menu_choices(items: List[Any], title_field: Optional[str] = None, value_field: Optional[str] = None) -> List[Any]:
267
+ """Prepare a list of items for `prompt_toolkit_menu` by adding index numbers and hotkeys."""
268
+ import questionary
269
+
270
+ special_keywords = {
271
+ "quit": "q", "[quit]": "q", "(q) [quit]": "q", "q": "q",
272
+ "back": "b", "[back]": "b", "(b) [back]": "b", "b": "b",
273
+ "menu": "m", "[menu]": "m", "return to menu": "m"
274
+ }
275
+
276
+ regular_items = []
277
+ special_items = []
278
+
279
+ for item in items:
280
+ title = ""
281
+ if isinstance(item, dict):
282
+ if title_field: title = str(item.get(title_field))
283
+ elif "title" in item: title = str(item["title"])
284
+ else: title = str(item)
285
+ else:
286
+ title = str(item)
287
+
288
+ lower_title = title.strip().lower()
289
+ if lower_title in special_keywords:
290
+ special_items.append((item, special_keywords[lower_title]))
291
+ else:
292
+ regular_items.append(item)
293
+
294
+ pad = len(str(len(regular_items)))
295
+ choices = []
296
+
297
+ for i, item in enumerate(regular_items, 1):
298
+ idx_str = str(i).zfill(pad)
299
+ if isinstance(item, dict):
300
+ title = item.get(title_field) if title_field else item.get("title", str(item))
301
+ value = item.get(value_field) if value_field else item.get("value", item)
302
+ else:
303
+ title = str(item)
304
+ value = item
305
+ choices.append(questionary.Choice(f"{idx_str}. {title}", value=value))
306
+
307
+ for item, key in special_items:
308
+ if isinstance(item, dict):
309
+ title = item.get(title_field) if title_field else item.get("title", str(item))
310
+ value = item.get(value_field) if value_field else item.get("value", item)
311
+ else:
312
+ title = str(item)
313
+ value = item
314
+ choices.append(questionary.Choice(f"{' ' * (pad - 1)}{key}. {title}", value=value))
315
+
316
+ return choices
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: ppui
3
+ Version: 2026.1.20.2
4
+ Summary: Python Process User Intent: A high-level abstract UI system (TUI, Web, Voice)
5
+ Project-URL: Homepage, https://github.com/nextoken/pas
6
+ Author-email: Next Token <nextoken@gmail.com>
7
+ License: MIT
8
+ Requires-Python: >=3.8
9
+ Requires-Dist: prompt-toolkit>=3.0.0
10
+ Requires-Dist: questionary>=2.0.0
11
+ Requires-Dist: rich>=13.0.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # PPUI: Python Process User Intent 🎭
15
+
16
+ A high-level abstract UI system for Python scripts.
17
+
18
+ PPUI (pronounced "pui") is the Python implementation of the **PUI** (**Process User Intent**) philosophy.
19
+
20
+ The goal isn't just to "draw a menu"—it's to capture what the user wants to do next. Whether that intent is captured via a terminal hotkey, a web click, or a voice command, the script's logic remains the same.
21
+
22
+ *PPUI: Because your scripts should care about what you want, not just how you say it.*
23
+
24
+ ## Features
25
+ - **High-level Abstractions**: Use concepts like `Selection` and `Input` without caring about the rendering engine.
26
+ - **TUI Implementation**: Professional, hotkey-driven terminal menus.
27
+ - **Submenu Support**: Nested selections with `push` (drill-down) or `inline` (expandable) behaviors.
28
+ - **Rich Integration**: Full support for Rich-formatted output and panels.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install ppui
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ### High-Level Menu Class
39
+
40
+ The `Menu` class provides a declarative way to build interactive loops with callback support.
41
+
42
+ ```python
43
+ from ppui import Menu
44
+
45
+ def run_setup():
46
+ print("Running setup...")
47
+
48
+ def main():
49
+ menu = Menu("Project Manager", style="bold green")
50
+ menu.add_option("Setup Project", run_setup)
51
+ menu.add_option("View Dashboard", "view_dash")
52
+
53
+ # Submenu support
54
+ advanced = Menu("Advanced Settings")
55
+ advanced.add_option("DNS Config", lambda: print("Configuring DNS..."))
56
+ menu.add_submenu("Advanced...", advanced, behavior="push")
57
+
58
+ menu.add_back_item()
59
+ menu.add_quit_item()
60
+
61
+ selection = menu.run()
62
+ ```
@@ -0,0 +1,6 @@
1
+ ppui/__init__.py,sha256=cq47TICGZ34Vj2jEHnH19Xi8K-9oA_HdMJU7gTTKkIk,424
2
+ ppui/base.py,sha256=BGjaE7i188vIu3K3QHnlo0tw6vgfVZydhlgeWiWZx2c,1711
3
+ ppui/core.py,sha256=gJAEF65Ly-qSmYURtD1xsZ7AduTz1WErv6a4Yz9J9Gs,11760
4
+ ppui-2026.1.20.2.dist-info/METADATA,sha256=idFznS6IKvRi0-ebh1fvkFfe3gBZkc_P8gmvyhMo3Uk,2022
5
+ ppui-2026.1.20.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ ppui-2026.1.20.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any