cmdgui 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.
- cmdgui-0.1.0/PKG-INFO +263 -0
- cmdgui-0.1.0/README.md +248 -0
- cmdgui-0.1.0/pyproject.toml +22 -0
- cmdgui-0.1.0/setup.cfg +4 -0
- cmdgui-0.1.0/src/cmdgui/__init__.py +14 -0
- cmdgui-0.1.0/src/cmdgui/_platform.py +126 -0
- cmdgui-0.1.0/src/cmdgui/inputs.py +214 -0
- cmdgui-0.1.0/src/cmdgui/layout.py +249 -0
- cmdgui-0.1.0/src/cmdgui/shorts.py +307 -0
- cmdgui-0.1.0/src/cmdgui/view.py +713 -0
- cmdgui-0.1.0/src/cmdgui/widgets.py +577 -0
- cmdgui-0.1.0/src/cmdgui.egg-info/PKG-INFO +263 -0
- cmdgui-0.1.0/src/cmdgui.egg-info/SOURCES.txt +13 -0
- cmdgui-0.1.0/src/cmdgui.egg-info/dependency_links.txt +1 -0
- cmdgui-0.1.0/src/cmdgui.egg-info/top_level.txt +1 -0
cmdgui-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cmdgui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple terminal GUIs: draw your layout as text, the view runs itself
|
|
5
|
+
Author: olliez-mods
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: homepage, https://github.com/olliez-mods/cmdgui
|
|
8
|
+
Keywords: terminal,tui,gui,cli,widgets
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# cmdgui
|
|
17
|
+
|
|
18
|
+
Simple terminal GUIs in Python. Draw your layout as text, fill in the widgets,
|
|
19
|
+
and keep writing your program — the view runs on its own thread, so there's no
|
|
20
|
+
event loop to hand control to and no draw calls to make.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
┌─ name ───────────────────┐
|
|
24
|
+
│quinn │[ Greet ] ON fast
|
|
25
|
+
├─ fruit ──────────────────┼─ scores ──────────────────────────────────┐
|
|
26
|
+
│ apple │name score city │
|
|
27
|
+
│ banana │Ada 98 London │
|
|
28
|
+
│ cherry │Linus 87 Helsinki │
|
|
29
|
+
│ ├─ log ─────────────────────────────────────┤
|
|
30
|
+
│ │Hello, quinn! 👋 │
|
|
31
|
+
│ │picked cherry │
|
|
32
|
+
└──────────────────────────┴───────────────────────────────────────────┘
|
|
33
|
+
████████████████████████████████░░░░░░░░░░░░░░░░ 67%[ ] done
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
No dependencies. macOS and Linux; Windows support is written but untested.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install cmdgui
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from cmdgui import View, Button, Stdout
|
|
48
|
+
import time
|
|
49
|
+
|
|
50
|
+
class App(View):
|
|
51
|
+
layout = """
|
|
52
|
+
start pause .
|
|
53
|
+
log - -
|
|
54
|
+
"""
|
|
55
|
+
start = Button("Start", on_click=lambda: print("started"))
|
|
56
|
+
pause = Button("Pause", on_click=lambda: print("paused"))
|
|
57
|
+
log = Stdout()
|
|
58
|
+
|
|
59
|
+
view = App()
|
|
60
|
+
|
|
61
|
+
for i in range(100):
|
|
62
|
+
print(f"tick {i}") # shows up in the log box
|
|
63
|
+
time.sleep(0.5)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Press `q` to quit (or Ctrl+C). Because the widgets are class attributes, your
|
|
67
|
+
editor knows `view.start` is a `Button` — autocomplete and type checking work.
|
|
68
|
+
|
|
69
|
+
### Three ways to build a view
|
|
70
|
+
|
|
71
|
+
They can be mixed freely:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from cmdgui import View, Label
|
|
75
|
+
|
|
76
|
+
# 1. A subclass: everything in one place, fully typed in your editor
|
|
77
|
+
class App(View):
|
|
78
|
+
layout = "heading \n stdout"
|
|
79
|
+
heading = Label("Hello", align="center")
|
|
80
|
+
view = App()
|
|
81
|
+
|
|
82
|
+
# 2. Widgets passed in: quick one-off views
|
|
83
|
+
view = View("heading \n stdout", heading=Label("Hello", align="center"))
|
|
84
|
+
|
|
85
|
+
# 3. Types in the layout, configured afterwards
|
|
86
|
+
view = View("label[heading] \n stdout")
|
|
87
|
+
view.heading.set(text="Hello", align="center")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A keyword argument overrides a subclass's widget of the same name. Each view made
|
|
91
|
+
from a subclass gets its own copies of the widgets. For typed access to widgets
|
|
92
|
+
declared in the layout string, use `view.get("heading", Label)`.
|
|
93
|
+
|
|
94
|
+
## Layouts
|
|
95
|
+
|
|
96
|
+
Each word is a grid cell. Write `type[name]` to create a widget:
|
|
97
|
+
|
|
98
|
+
| Token | Meaning |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `button[start]` | a `button` widget named `start` |
|
|
101
|
+
| `heading` | the widget you passed in (or declared on the class) as `heading` |
|
|
102
|
+
| `stdout` | otherwise: a widget whose name is its type |
|
|
103
|
+
| `start` | the existing widget `start` again — it spans into this cell |
|
|
104
|
+
| `-` | same as the cell to the left |
|
|
105
|
+
| `\|` | same as the cell above |
|
|
106
|
+
| `.` | empty cell |
|
|
107
|
+
| `{b}` / `{nb}` | on the first cell of a widget: force a border on / off |
|
|
108
|
+
|
|
109
|
+
Every widget has to cover a rectangle. Get widgets with `view.start` or `view["start"]`.
|
|
110
|
+
|
|
111
|
+
**Sizes** come from the widgets: a button wants to be 1 line tall, so its row is
|
|
112
|
+
1 line tall; everything else shares the remaining space. If the terminal is too
|
|
113
|
+
small for the layout's minimum size, the view shows a message until it's resized.
|
|
114
|
+
|
|
115
|
+
**Borders** are drawn by the view, and neighbours share a single line with the
|
|
116
|
+
right junctions (`├ ┬ ┼`). The border title is the widget's name, or its `title`.
|
|
117
|
+
The focused widget's border is highlighted.
|
|
118
|
+
|
|
119
|
+
## Widgets
|
|
120
|
+
|
|
121
|
+
| Type | Class | What it does |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| `text` | `Text` | Wrapped text. `text`, `align` (`left`/`center`/`right`) |
|
|
124
|
+
| `label` | `Label` | One line of text |
|
|
125
|
+
| `button` | `Button` | `text`, `on_click(fn)`. Click, or Enter/Space when focused |
|
|
126
|
+
| `text_input` | `TextInput` | `value`, `placeholder`, `on_submit(fn)`, `on_change(fn)` |
|
|
127
|
+
| `progress_bar` | `ProgressBar` | `value` from 0 to 1 |
|
|
128
|
+
| `checkbox` | `Checkbox` | `text`, `checked`, `on_change(fn)` |
|
|
129
|
+
| `toggle` | `Toggle` | An on/off switch, same API as `Checkbox` |
|
|
130
|
+
| `menu` | `Menu` | `items`, `selected`, `on_select(fn(index, item))` |
|
|
131
|
+
| `table` | `Table` | `columns`, `rows`. Scroll with the mouse wheel |
|
|
132
|
+
| `stdout` | `Stdout` | Everything printed, stderr in red. Scroll with the mouse wheel. `clear()` |
|
|
133
|
+
|
|
134
|
+
Every widget also takes `border`, `title`, `preferred_width` and `preferred_height`.
|
|
135
|
+
The first positional argument is the main content: `Label("text")`, `Menu(items)`,
|
|
136
|
+
`Table(columns)`, `ProgressBar(0.5)`. Callbacks can be passed in (`on_click=`) or set
|
|
137
|
+
later (`button.on_click(fn)`).
|
|
138
|
+
|
|
139
|
+
Setting an attribute redraws the widget: `view.bar.value = 0.5` just works, and
|
|
140
|
+
`widget.set(text=..., align=...)` changes several at once. If you change a list in
|
|
141
|
+
place (`view.menu.items.append(...)`), call `widget.refresh()`.
|
|
142
|
+
|
|
143
|
+
## Popups
|
|
144
|
+
|
|
145
|
+
A popup floats over the layout. It has its own layout and widgets, just like a view,
|
|
146
|
+
and sizes itself to fit them:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from cmdgui import View, Popup, Label, Button, Stdout
|
|
150
|
+
|
|
151
|
+
class Confirm(Popup):
|
|
152
|
+
layout = """
|
|
153
|
+
message -
|
|
154
|
+
yes no
|
|
155
|
+
"""
|
|
156
|
+
title = "Clear the log?"
|
|
157
|
+
message = Label("This can't be undone.")
|
|
158
|
+
yes = Button("Clear")
|
|
159
|
+
no = Button("Cancel")
|
|
160
|
+
|
|
161
|
+
def init(self): # wire up the popup's own widgets
|
|
162
|
+
self.no.on_click(self.close)
|
|
163
|
+
|
|
164
|
+
class App(View):
|
|
165
|
+
layout = "clear \n log"
|
|
166
|
+
clear = Button("Clear log", on_click=lambda: view.show(view.confirm))
|
|
167
|
+
log = Stdout()
|
|
168
|
+
confirm = Confirm() # popups can live on the view too
|
|
169
|
+
|
|
170
|
+
view = App()
|
|
171
|
+
view.confirm.yes.on_click(lambda: (view.log.clear(), view.confirm.close()))
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- `view.show(popup)` opens it in the middle, or use `below=widget`, `above=widget` or
|
|
175
|
+
`at=(x, y)`. It flips to the other side if there's no room.
|
|
176
|
+
- `popup.close()`, `popup.is_open`, `popup.on_close(fn)`.
|
|
177
|
+
- `view.alert("Saved!", title="Done")` shows a message with an OK button.
|
|
178
|
+
- A popup with one widget doesn't need a layout: `Popup(Menu(items))`, or a subclass
|
|
179
|
+
with a single widget.
|
|
180
|
+
|
|
181
|
+
Settings, as class attributes or constructor arguments:
|
|
182
|
+
|
|
183
|
+
| Setting | Default | |
|
|
184
|
+
|---|---|---|
|
|
185
|
+
| `modal` | `True` | blocks clicks and focus for everything underneath |
|
|
186
|
+
| `close_on_escape` | `True` | |
|
|
187
|
+
| `close_on_outside_click` | `False` | for a modal popup the click just closes it; otherwise it goes through too |
|
|
188
|
+
| `keep_typing` | `False` | the focused text box keeps getting typed text, while arrows and Enter go to the popup (autocomplete, command menus) |
|
|
189
|
+
| `border`, `title` | `True`, `None` | |
|
|
190
|
+
| `width`, `height` | `None` | outer size; `None` fits the content |
|
|
191
|
+
|
|
192
|
+
Inside a popup, a widget's border title is only shown if you set its `title`.
|
|
193
|
+
`examples/simple_console.py` has a `/` command menu and `examples/popups.py` has a dialog and a dropdown.
|
|
194
|
+
|
|
195
|
+
## Keys and focus
|
|
196
|
+
|
|
197
|
+
- **Tab** / **Shift+Tab** or a click moves focus between widgets that take input.
|
|
198
|
+
- Key presses go to the focused widget; typed characters go to a focused text box first.
|
|
199
|
+
- `view.on_key("ctrl+s", save)` binds a key. Key names: letters, `enter`, `escape`,
|
|
200
|
+
`tab`, `backspace`, `delete`, `up`/`down`/`left`/`right`, `home`/`end`,
|
|
201
|
+
`page_up`/`page_down`, `ctrl+a`, `alt+x`, `ctrl+up`, ...
|
|
202
|
+
- `View(layout, quit_key="q")` sets the quit key; `quit_key=None` turns it off.
|
|
203
|
+
|
|
204
|
+
## Ending the program
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
with View("...") as view:
|
|
208
|
+
...
|
|
209
|
+
view.wait() # blocks until q, view.quit() or Ctrl+C
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Without `wait()`, the quit key ends your program as if it had finished.
|
|
213
|
+
If anything raises — in your code or inside a callback — the terminal is put back
|
|
214
|
+
to normal first, so the traceback is visible.
|
|
215
|
+
|
|
216
|
+
## Your own widgets
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
from cmdgui import Widget, View, field
|
|
220
|
+
|
|
221
|
+
class Clock(Widget):
|
|
222
|
+
time: str = field(default="", kw_only=False) # fields become constructor arguments
|
|
223
|
+
show_seconds: bool = True
|
|
224
|
+
history: list = field(default_factory=list) # a fresh list for each clock
|
|
225
|
+
|
|
226
|
+
preferred_height = 1 # 5, "5+" (at least), "5-10" (between), or None
|
|
227
|
+
border = True
|
|
228
|
+
|
|
229
|
+
def init(self): # other setup (not constructor arguments)
|
|
230
|
+
self.ticks = 0
|
|
231
|
+
|
|
232
|
+
def draw(self, c): # c is a Canvas exactly the widget's size
|
|
233
|
+
c.text(0, 0, self.time)
|
|
234
|
+
|
|
235
|
+
def on_input(self, input): # mouse, stdout, and keys (while focused)
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
view = View("clock") # registered automatically as "clock"
|
|
239
|
+
view = View("now", now=Clock("12:00"))
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Annotated attributes are fields: keyword arguments by default, positional with
|
|
243
|
+
`field(kw_only=False)`. Editors autocomplete and type-check them like a dataclass.
|
|
244
|
+
|
|
245
|
+
Override `content_size()` to return the `(width, height)` your content wants, so
|
|
246
|
+
popups can size themselves around it (`None` for either means "don't care").
|
|
247
|
+
|
|
248
|
+
`Canvas` has `put`, `text`, `fill` and `border`; `self.mouse_pos()` and
|
|
249
|
+
`self.mouse_over()` give the mouse relative to the widget; `self.theme("key")`
|
|
250
|
+
gets a style from the theme.
|
|
251
|
+
|
|
252
|
+
## Themes
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
from cmdgui import View, style
|
|
256
|
+
|
|
257
|
+
view = View("...", theme={
|
|
258
|
+
"border_focus": style(fg="magenta", bold=True),
|
|
259
|
+
"selected": style(fg="black", bg="yellow"),
|
|
260
|
+
})
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
See `DEFAULT_THEME` in `cmdgui.widgets` for every key.
|
cmdgui-0.1.0/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# cmdgui
|
|
2
|
+
|
|
3
|
+
Simple terminal GUIs in Python. Draw your layout as text, fill in the widgets,
|
|
4
|
+
and keep writing your program — the view runs on its own thread, so there's no
|
|
5
|
+
event loop to hand control to and no draw calls to make.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─ name ───────────────────┐
|
|
9
|
+
│quinn │[ Greet ] ON fast
|
|
10
|
+
├─ fruit ──────────────────┼─ scores ──────────────────────────────────┐
|
|
11
|
+
│ apple │name score city │
|
|
12
|
+
│ banana │Ada 98 London │
|
|
13
|
+
│ cherry │Linus 87 Helsinki │
|
|
14
|
+
│ ├─ log ─────────────────────────────────────┤
|
|
15
|
+
│ │Hello, quinn! 👋 │
|
|
16
|
+
│ │picked cherry │
|
|
17
|
+
└──────────────────────────┴───────────────────────────────────────────┘
|
|
18
|
+
████████████████████████████████░░░░░░░░░░░░░░░░ 67%[ ] done
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
No dependencies. macOS and Linux; Windows support is written but untested.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install cmdgui
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from cmdgui import View, Button, Stdout
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
class App(View):
|
|
36
|
+
layout = """
|
|
37
|
+
start pause .
|
|
38
|
+
log - -
|
|
39
|
+
"""
|
|
40
|
+
start = Button("Start", on_click=lambda: print("started"))
|
|
41
|
+
pause = Button("Pause", on_click=lambda: print("paused"))
|
|
42
|
+
log = Stdout()
|
|
43
|
+
|
|
44
|
+
view = App()
|
|
45
|
+
|
|
46
|
+
for i in range(100):
|
|
47
|
+
print(f"tick {i}") # shows up in the log box
|
|
48
|
+
time.sleep(0.5)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Press `q` to quit (or Ctrl+C). Because the widgets are class attributes, your
|
|
52
|
+
editor knows `view.start` is a `Button` — autocomplete and type checking work.
|
|
53
|
+
|
|
54
|
+
### Three ways to build a view
|
|
55
|
+
|
|
56
|
+
They can be mixed freely:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from cmdgui import View, Label
|
|
60
|
+
|
|
61
|
+
# 1. A subclass: everything in one place, fully typed in your editor
|
|
62
|
+
class App(View):
|
|
63
|
+
layout = "heading \n stdout"
|
|
64
|
+
heading = Label("Hello", align="center")
|
|
65
|
+
view = App()
|
|
66
|
+
|
|
67
|
+
# 2. Widgets passed in: quick one-off views
|
|
68
|
+
view = View("heading \n stdout", heading=Label("Hello", align="center"))
|
|
69
|
+
|
|
70
|
+
# 3. Types in the layout, configured afterwards
|
|
71
|
+
view = View("label[heading] \n stdout")
|
|
72
|
+
view.heading.set(text="Hello", align="center")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
A keyword argument overrides a subclass's widget of the same name. Each view made
|
|
76
|
+
from a subclass gets its own copies of the widgets. For typed access to widgets
|
|
77
|
+
declared in the layout string, use `view.get("heading", Label)`.
|
|
78
|
+
|
|
79
|
+
## Layouts
|
|
80
|
+
|
|
81
|
+
Each word is a grid cell. Write `type[name]` to create a widget:
|
|
82
|
+
|
|
83
|
+
| Token | Meaning |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `button[start]` | a `button` widget named `start` |
|
|
86
|
+
| `heading` | the widget you passed in (or declared on the class) as `heading` |
|
|
87
|
+
| `stdout` | otherwise: a widget whose name is its type |
|
|
88
|
+
| `start` | the existing widget `start` again — it spans into this cell |
|
|
89
|
+
| `-` | same as the cell to the left |
|
|
90
|
+
| `\|` | same as the cell above |
|
|
91
|
+
| `.` | empty cell |
|
|
92
|
+
| `{b}` / `{nb}` | on the first cell of a widget: force a border on / off |
|
|
93
|
+
|
|
94
|
+
Every widget has to cover a rectangle. Get widgets with `view.start` or `view["start"]`.
|
|
95
|
+
|
|
96
|
+
**Sizes** come from the widgets: a button wants to be 1 line tall, so its row is
|
|
97
|
+
1 line tall; everything else shares the remaining space. If the terminal is too
|
|
98
|
+
small for the layout's minimum size, the view shows a message until it's resized.
|
|
99
|
+
|
|
100
|
+
**Borders** are drawn by the view, and neighbours share a single line with the
|
|
101
|
+
right junctions (`├ ┬ ┼`). The border title is the widget's name, or its `title`.
|
|
102
|
+
The focused widget's border is highlighted.
|
|
103
|
+
|
|
104
|
+
## Widgets
|
|
105
|
+
|
|
106
|
+
| Type | Class | What it does |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `text` | `Text` | Wrapped text. `text`, `align` (`left`/`center`/`right`) |
|
|
109
|
+
| `label` | `Label` | One line of text |
|
|
110
|
+
| `button` | `Button` | `text`, `on_click(fn)`. Click, or Enter/Space when focused |
|
|
111
|
+
| `text_input` | `TextInput` | `value`, `placeholder`, `on_submit(fn)`, `on_change(fn)` |
|
|
112
|
+
| `progress_bar` | `ProgressBar` | `value` from 0 to 1 |
|
|
113
|
+
| `checkbox` | `Checkbox` | `text`, `checked`, `on_change(fn)` |
|
|
114
|
+
| `toggle` | `Toggle` | An on/off switch, same API as `Checkbox` |
|
|
115
|
+
| `menu` | `Menu` | `items`, `selected`, `on_select(fn(index, item))` |
|
|
116
|
+
| `table` | `Table` | `columns`, `rows`. Scroll with the mouse wheel |
|
|
117
|
+
| `stdout` | `Stdout` | Everything printed, stderr in red. Scroll with the mouse wheel. `clear()` |
|
|
118
|
+
|
|
119
|
+
Every widget also takes `border`, `title`, `preferred_width` and `preferred_height`.
|
|
120
|
+
The first positional argument is the main content: `Label("text")`, `Menu(items)`,
|
|
121
|
+
`Table(columns)`, `ProgressBar(0.5)`. Callbacks can be passed in (`on_click=`) or set
|
|
122
|
+
later (`button.on_click(fn)`).
|
|
123
|
+
|
|
124
|
+
Setting an attribute redraws the widget: `view.bar.value = 0.5` just works, and
|
|
125
|
+
`widget.set(text=..., align=...)` changes several at once. If you change a list in
|
|
126
|
+
place (`view.menu.items.append(...)`), call `widget.refresh()`.
|
|
127
|
+
|
|
128
|
+
## Popups
|
|
129
|
+
|
|
130
|
+
A popup floats over the layout. It has its own layout and widgets, just like a view,
|
|
131
|
+
and sizes itself to fit them:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from cmdgui import View, Popup, Label, Button, Stdout
|
|
135
|
+
|
|
136
|
+
class Confirm(Popup):
|
|
137
|
+
layout = """
|
|
138
|
+
message -
|
|
139
|
+
yes no
|
|
140
|
+
"""
|
|
141
|
+
title = "Clear the log?"
|
|
142
|
+
message = Label("This can't be undone.")
|
|
143
|
+
yes = Button("Clear")
|
|
144
|
+
no = Button("Cancel")
|
|
145
|
+
|
|
146
|
+
def init(self): # wire up the popup's own widgets
|
|
147
|
+
self.no.on_click(self.close)
|
|
148
|
+
|
|
149
|
+
class App(View):
|
|
150
|
+
layout = "clear \n log"
|
|
151
|
+
clear = Button("Clear log", on_click=lambda: view.show(view.confirm))
|
|
152
|
+
log = Stdout()
|
|
153
|
+
confirm = Confirm() # popups can live on the view too
|
|
154
|
+
|
|
155
|
+
view = App()
|
|
156
|
+
view.confirm.yes.on_click(lambda: (view.log.clear(), view.confirm.close()))
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- `view.show(popup)` opens it in the middle, or use `below=widget`, `above=widget` or
|
|
160
|
+
`at=(x, y)`. It flips to the other side if there's no room.
|
|
161
|
+
- `popup.close()`, `popup.is_open`, `popup.on_close(fn)`.
|
|
162
|
+
- `view.alert("Saved!", title="Done")` shows a message with an OK button.
|
|
163
|
+
- A popup with one widget doesn't need a layout: `Popup(Menu(items))`, or a subclass
|
|
164
|
+
with a single widget.
|
|
165
|
+
|
|
166
|
+
Settings, as class attributes or constructor arguments:
|
|
167
|
+
|
|
168
|
+
| Setting | Default | |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `modal` | `True` | blocks clicks and focus for everything underneath |
|
|
171
|
+
| `close_on_escape` | `True` | |
|
|
172
|
+
| `close_on_outside_click` | `False` | for a modal popup the click just closes it; otherwise it goes through too |
|
|
173
|
+
| `keep_typing` | `False` | the focused text box keeps getting typed text, while arrows and Enter go to the popup (autocomplete, command menus) |
|
|
174
|
+
| `border`, `title` | `True`, `None` | |
|
|
175
|
+
| `width`, `height` | `None` | outer size; `None` fits the content |
|
|
176
|
+
|
|
177
|
+
Inside a popup, a widget's border title is only shown if you set its `title`.
|
|
178
|
+
`examples/simple_console.py` has a `/` command menu and `examples/popups.py` has a dialog and a dropdown.
|
|
179
|
+
|
|
180
|
+
## Keys and focus
|
|
181
|
+
|
|
182
|
+
- **Tab** / **Shift+Tab** or a click moves focus between widgets that take input.
|
|
183
|
+
- Key presses go to the focused widget; typed characters go to a focused text box first.
|
|
184
|
+
- `view.on_key("ctrl+s", save)` binds a key. Key names: letters, `enter`, `escape`,
|
|
185
|
+
`tab`, `backspace`, `delete`, `up`/`down`/`left`/`right`, `home`/`end`,
|
|
186
|
+
`page_up`/`page_down`, `ctrl+a`, `alt+x`, `ctrl+up`, ...
|
|
187
|
+
- `View(layout, quit_key="q")` sets the quit key; `quit_key=None` turns it off.
|
|
188
|
+
|
|
189
|
+
## Ending the program
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
with View("...") as view:
|
|
193
|
+
...
|
|
194
|
+
view.wait() # blocks until q, view.quit() or Ctrl+C
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Without `wait()`, the quit key ends your program as if it had finished.
|
|
198
|
+
If anything raises — in your code or inside a callback — the terminal is put back
|
|
199
|
+
to normal first, so the traceback is visible.
|
|
200
|
+
|
|
201
|
+
## Your own widgets
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from cmdgui import Widget, View, field
|
|
205
|
+
|
|
206
|
+
class Clock(Widget):
|
|
207
|
+
time: str = field(default="", kw_only=False) # fields become constructor arguments
|
|
208
|
+
show_seconds: bool = True
|
|
209
|
+
history: list = field(default_factory=list) # a fresh list for each clock
|
|
210
|
+
|
|
211
|
+
preferred_height = 1 # 5, "5+" (at least), "5-10" (between), or None
|
|
212
|
+
border = True
|
|
213
|
+
|
|
214
|
+
def init(self): # other setup (not constructor arguments)
|
|
215
|
+
self.ticks = 0
|
|
216
|
+
|
|
217
|
+
def draw(self, c): # c is a Canvas exactly the widget's size
|
|
218
|
+
c.text(0, 0, self.time)
|
|
219
|
+
|
|
220
|
+
def on_input(self, input): # mouse, stdout, and keys (while focused)
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
view = View("clock") # registered automatically as "clock"
|
|
224
|
+
view = View("now", now=Clock("12:00"))
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Annotated attributes are fields: keyword arguments by default, positional with
|
|
228
|
+
`field(kw_only=False)`. Editors autocomplete and type-check them like a dataclass.
|
|
229
|
+
|
|
230
|
+
Override `content_size()` to return the `(width, height)` your content wants, so
|
|
231
|
+
popups can size themselves around it (`None` for either means "don't care").
|
|
232
|
+
|
|
233
|
+
`Canvas` has `put`, `text`, `fill` and `border`; `self.mouse_pos()` and
|
|
234
|
+
`self.mouse_over()` give the mouse relative to the widget; `self.theme("key")`
|
|
235
|
+
gets a style from the theme.
|
|
236
|
+
|
|
237
|
+
## Themes
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from cmdgui import View, style
|
|
241
|
+
|
|
242
|
+
view = View("...", theme={
|
|
243
|
+
"border_focus": style(fg="magenta", bold=True),
|
|
244
|
+
"selected": style(fg="black", bg="yellow"),
|
|
245
|
+
})
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
See `DEFAULT_THEME` in `cmdgui.widgets` for every key.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cmdgui"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Simple terminal GUIs: draw your layout as text, the view runs itself"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
keywords = ["terminal", "tui", "gui", "cli", "widgets"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Operating System :: MacOS",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
]
|
|
18
|
+
license = "MIT"
|
|
19
|
+
authors = [{name = "olliez-mods"}]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
homepage = "https://github.com/olliez-mods/cmdgui"
|
cmdgui-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .view import View, Popup
|
|
2
|
+
from .widgets import (
|
|
3
|
+
Widget, Text, Label, Button, TextInput, ProgressBar, Checkbox, Toggle,
|
|
4
|
+
Menu, Table, Stdout, DrawMouse, DEFAULT_THEME, field,
|
|
5
|
+
)
|
|
6
|
+
from .inputs import Input, mouse
|
|
7
|
+
from .layout import LayoutError
|
|
8
|
+
from .shorts import Canvas, style
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"View", "Popup", "Widget", "Text", "Label", "Button", "TextInput", "ProgressBar",
|
|
12
|
+
"Checkbox", "Toggle", "Menu", "Table", "Stdout", "DrawMouse", "DEFAULT_THEME", "field",
|
|
13
|
+
"Input", "mouse", "LayoutError", "Canvas", "style",
|
|
14
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The OS-specific bits: raw keyboard mode, reading input with a timeout, and
|
|
2
|
+
waking a blocked read from another thread. Everything else is shared."""
|
|
3
|
+
import codecs
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
WINDOWS = os.name == "nt"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
if not WINDOWS:
|
|
11
|
+
import select
|
|
12
|
+
import termios
|
|
13
|
+
|
|
14
|
+
class Terminal:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self._saved = None
|
|
17
|
+
self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
18
|
+
self._wake_r, self._wake_w = os.pipe()
|
|
19
|
+
os.set_blocking(self._wake_w, False)
|
|
20
|
+
|
|
21
|
+
def enable(self):
|
|
22
|
+
fd = sys.stdin.fileno()
|
|
23
|
+
self._saved = termios.tcgetattr(fd)
|
|
24
|
+
attrs = termios.tcgetattr(fd)
|
|
25
|
+
# No line buffering, no echo. ISIG stays on so Ctrl+C still raises KeyboardInterrupt.
|
|
26
|
+
attrs[3] &= ~(termios.ICANON | termios.ECHO)
|
|
27
|
+
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
|
28
|
+
|
|
29
|
+
def disable(self):
|
|
30
|
+
if self._saved is not None:
|
|
31
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, self._saved)
|
|
32
|
+
self._saved = None
|
|
33
|
+
|
|
34
|
+
def read(self, timeout):
|
|
35
|
+
"""Wait up to timeout seconds, return whatever was typed ("" if nothing)."""
|
|
36
|
+
fd = sys.stdin.fileno()
|
|
37
|
+
ready, _, _ = select.select([fd, self._wake_r], [], [], timeout)
|
|
38
|
+
text = ""
|
|
39
|
+
if fd in ready:
|
|
40
|
+
text = self._decoder.decode(os.read(fd, 1024)) # handles characters split across reads
|
|
41
|
+
if self._wake_r in ready:
|
|
42
|
+
os.read(self._wake_r, 4096)
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
def wake(self):
|
|
46
|
+
"""Make a read() that's waiting return straight away. Safe from any thread."""
|
|
47
|
+
try:
|
|
48
|
+
os.write(self._wake_w, b"x")
|
|
49
|
+
except BlockingIOError:
|
|
50
|
+
pass # pipe full, it's already going to wake up
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
else:
|
|
54
|
+
import ctypes
|
|
55
|
+
from ctypes import wintypes
|
|
56
|
+
|
|
57
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
58
|
+
|
|
59
|
+
STD_INPUT_HANDLE, STD_OUTPUT_HANDLE = -10, -11
|
|
60
|
+
ENABLE_PROCESSED_INPUT = 0x0001
|
|
61
|
+
ENABLE_LINE_INPUT = 0x0002
|
|
62
|
+
ENABLE_ECHO_INPUT = 0x0004
|
|
63
|
+
ENABLE_QUICK_EDIT_MODE = 0x0040
|
|
64
|
+
ENABLE_EXTENDED_FLAGS = 0x0080
|
|
65
|
+
ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
|
|
66
|
+
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
|
|
67
|
+
KEY_EVENT = 0x0001
|
|
68
|
+
WAIT_TIMEOUT = 0x102
|
|
69
|
+
|
|
70
|
+
class KEY_EVENT_RECORD(ctypes.Structure):
|
|
71
|
+
_fields_ = [("bKeyDown", wintypes.BOOL), ("wRepeatCount", wintypes.WORD),
|
|
72
|
+
("wVirtualKeyCode", wintypes.WORD), ("wVirtualScanCode", wintypes.WORD),
|
|
73
|
+
("UnicodeChar", wintypes.WCHAR), ("dwControlKeyState", wintypes.DWORD)]
|
|
74
|
+
|
|
75
|
+
class _EVENT(ctypes.Union):
|
|
76
|
+
# KEY_EVENT_RECORD is the largest member we read; pad to the real union size (16 bytes)
|
|
77
|
+
_fields_ = [("KeyEvent", KEY_EVENT_RECORD), ("_pad", ctypes.c_byte * 16)]
|
|
78
|
+
|
|
79
|
+
class INPUT_RECORD(ctypes.Structure):
|
|
80
|
+
_fields_ = [("EventType", wintypes.WORD), ("Event", _EVENT)]
|
|
81
|
+
|
|
82
|
+
class Terminal:
|
|
83
|
+
"""Windows console. With virtual terminal input on, keys and the mouse
|
|
84
|
+
arrive as the same escape sequences Unix terminals send."""
|
|
85
|
+
def __init__(self):
|
|
86
|
+
self._in = kernel32.GetStdHandle(STD_INPUT_HANDLE)
|
|
87
|
+
self._out = kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
|
|
88
|
+
self._saved = None
|
|
89
|
+
self._wake_event = kernel32.CreateEventW(None, False, False, None)
|
|
90
|
+
|
|
91
|
+
def enable(self):
|
|
92
|
+
in_mode, out_mode = wintypes.DWORD(), wintypes.DWORD()
|
|
93
|
+
kernel32.GetConsoleMode(self._in, ctypes.byref(in_mode))
|
|
94
|
+
kernel32.GetConsoleMode(self._out, ctypes.byref(out_mode))
|
|
95
|
+
self._saved = (in_mode.value, out_mode.value)
|
|
96
|
+
new_in = (in_mode.value | ENABLE_VIRTUAL_TERMINAL_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_PROCESSED_INPUT) \
|
|
97
|
+
& ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_QUICK_EDIT_MODE)
|
|
98
|
+
kernel32.SetConsoleMode(self._in, new_in)
|
|
99
|
+
kernel32.SetConsoleMode(self._out, out_mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
|
|
100
|
+
|
|
101
|
+
def disable(self):
|
|
102
|
+
if self._saved is not None:
|
|
103
|
+
kernel32.SetConsoleMode(self._in, self._saved[0])
|
|
104
|
+
kernel32.SetConsoleMode(self._out, self._saved[1])
|
|
105
|
+
self._saved = None
|
|
106
|
+
|
|
107
|
+
def read(self, timeout):
|
|
108
|
+
handles = (wintypes.HANDLE * 2)(self._in, self._wake_event)
|
|
109
|
+
result = kernel32.WaitForMultipleObjects(2, handles, False, int(timeout * 1000))
|
|
110
|
+
if result != 0: # timed out, or woken
|
|
111
|
+
return ""
|
|
112
|
+
text = []
|
|
113
|
+
count = wintypes.DWORD()
|
|
114
|
+
kernel32.GetNumberOfConsoleInputEvents(self._in, ctypes.byref(count))
|
|
115
|
+
if count.value:
|
|
116
|
+
records = (INPUT_RECORD * count.value)()
|
|
117
|
+
read = wintypes.DWORD()
|
|
118
|
+
kernel32.ReadConsoleInputW(self._in, records, count.value, ctypes.byref(read))
|
|
119
|
+
for record in records[:read.value]:
|
|
120
|
+
key = record.Event.KeyEvent
|
|
121
|
+
if record.EventType == KEY_EVENT and key.bKeyDown and key.UnicodeChar != "\0":
|
|
122
|
+
text.append(key.UnicodeChar * max(1, key.wRepeatCount))
|
|
123
|
+
return "".join(text)
|
|
124
|
+
|
|
125
|
+
def wake(self):
|
|
126
|
+
kernel32.SetEvent(self._wake_event)
|