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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)