cmdgui 0.1.0__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.
- cmdgui/__init__.py +14 -0
- cmdgui/_platform.py +126 -0
- cmdgui/inputs.py +214 -0
- cmdgui/layout.py +249 -0
- cmdgui/shorts.py +307 -0
- cmdgui/view.py +713 -0
- cmdgui/widgets.py +577 -0
- cmdgui-0.1.0.dist-info/METADATA +263 -0
- cmdgui-0.1.0.dist-info/RECORD +11 -0
- cmdgui-0.1.0.dist-info/WHEEL +5 -0
- cmdgui-0.1.0.dist-info/top_level.txt +1 -0
cmdgui/__init__.py
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
|
+
]
|
cmdgui/_platform.py
ADDED
|
@@ -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)
|
cmdgui/inputs.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import queue
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .shorts import ESC, write
|
|
6
|
+
from ._platform import Terminal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Input():
|
|
10
|
+
def __init__(self, type, details):
|
|
11
|
+
self.type:str = type
|
|
12
|
+
self.details:dict = details
|
|
13
|
+
|
|
14
|
+
def __repr__(self):
|
|
15
|
+
return f"Input({self.type!r}, {self.details!r})"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Mouse():
|
|
19
|
+
"""Current mouse state, kept up to date by the view before each input is
|
|
20
|
+
handed to widgets. Coordinates are 0-based screen positions."""
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.x = 0
|
|
23
|
+
self.y = 0
|
|
24
|
+
self.prev_x = 0 # position before the current input
|
|
25
|
+
self.prev_y = 0
|
|
26
|
+
self.buttons = set() # held buttons: 0 left, 1 middle, 2 right
|
|
27
|
+
|
|
28
|
+
def pos(self):
|
|
29
|
+
return self.x, self.y
|
|
30
|
+
|
|
31
|
+
def is_down(self, button=0):
|
|
32
|
+
return button in self.buttons
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def moved(self):
|
|
36
|
+
"""True if the current input moved the mouse to a different cell."""
|
|
37
|
+
return (self.x, self.y) != (self.prev_x, self.prev_y)
|
|
38
|
+
|
|
39
|
+
mouse = Mouse()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def update_state(input):
|
|
43
|
+
"""Apply an input to the shared state (mouse position and buttons)."""
|
|
44
|
+
if not input.type.startswith("mouse"):
|
|
45
|
+
return
|
|
46
|
+
mouse.prev_x, mouse.prev_y = mouse.x, mouse.y
|
|
47
|
+
mouse.x, mouse.y = input.details["x"], input.details["y"]
|
|
48
|
+
if input.type == "mouse_down":
|
|
49
|
+
mouse.buttons.add(input.details["button"])
|
|
50
|
+
elif input.type == "mouse_up":
|
|
51
|
+
mouse.buttons.discard(input.details["button"])
|
|
52
|
+
elif input.type == "mouse_move" and "button" not in input.details:
|
|
53
|
+
mouse.buttons.clear() # no button held; fixes a release we missed (e.g. outside the window)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Mouse tracking: 1003 = report all motion (not just while a button is held),
|
|
57
|
+
# 1006 = SGR encoding, which gives readable decimal coordinates with no size limit
|
|
58
|
+
MOUSE_ON = ESC + "?1003h" + ESC + "?1006h"
|
|
59
|
+
MOUSE_OFF = ESC + "?1003l" + ESC + "?1006l"
|
|
60
|
+
|
|
61
|
+
# SGR mouse report: ESC [ < button ; x ; y then M (press/move) or m (release)
|
|
62
|
+
SGR_MOUSE = re.compile(r"\x1b\[<(\d+);(\d+);(\d+)([Mm])")
|
|
63
|
+
# Other escape sequences: ESC [ params final, or ESC O final (some terminals' arrow keys)
|
|
64
|
+
CSI = re.compile(r"\x1b\[([0-9;]*)([A-Za-z~])")
|
|
65
|
+
SS3 = re.compile(r"\x1bO([A-Za-z])")
|
|
66
|
+
|
|
67
|
+
KEY_NAMES = {
|
|
68
|
+
"A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end", "Z": "shift_tab",
|
|
69
|
+
"1~": "home", "2~": "insert", "3~": "delete", "4~": "end", "5~": "page_up", "6~": "page_down",
|
|
70
|
+
"7~": "home", "8~": "end",
|
|
71
|
+
}
|
|
72
|
+
CHAR_NAMES = {"\r": "enter", "\n": "enter", "\t": "tab", "\x7f": "backspace", "\x08": "backspace",
|
|
73
|
+
"\x1b": "escape", " ": "space"}
|
|
74
|
+
MODIFIERS = {"2": "shift", "3": "alt", "4": "alt+shift", "5": "ctrl", "6": "ctrl+shift", "7": "ctrl+alt"}
|
|
75
|
+
|
|
76
|
+
_terminal = Terminal()
|
|
77
|
+
_buffer = ""
|
|
78
|
+
|
|
79
|
+
# Text written to sys.stdout / sys.stderr while intercepted
|
|
80
|
+
_text_queue = queue.SimpleQueue()
|
|
81
|
+
_stderr_log = [] # everything written to stderr, shown again after the view closes
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class StreamInterceptor:
|
|
85
|
+
"""Stands in for sys.stdout or sys.stderr and turns everything written
|
|
86
|
+
into "stdout" / "stderr" inputs."""
|
|
87
|
+
def __init__(self, real, kind):
|
|
88
|
+
self.real = real
|
|
89
|
+
self.kind = kind
|
|
90
|
+
def write(self, s):
|
|
91
|
+
if s:
|
|
92
|
+
_text_queue.put((self.kind, s))
|
|
93
|
+
if self.kind == "stderr":
|
|
94
|
+
_stderr_log.append(s)
|
|
95
|
+
_terminal.wake()
|
|
96
|
+
return len(s)
|
|
97
|
+
def flush(self):
|
|
98
|
+
pass
|
|
99
|
+
def __getattr__(self, name):
|
|
100
|
+
# Anything else (encoding, fileno, isatty...) comes from the real stream
|
|
101
|
+
return getattr(self.real, name)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def enable():
|
|
105
|
+
"""Raw keyboard mode, mouse reporting, and capture print output."""
|
|
106
|
+
_stderr_log.clear()
|
|
107
|
+
_terminal.enable()
|
|
108
|
+
write(MOUSE_ON)
|
|
109
|
+
sys.stdout = StreamInterceptor(sys.stdout, "stdout")
|
|
110
|
+
sys.stderr = StreamInterceptor(sys.stderr, "stderr")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def disable():
|
|
114
|
+
"""Undo enable(). Safe to call more than once."""
|
|
115
|
+
if isinstance(sys.stdout, StreamInterceptor):
|
|
116
|
+
sys.stdout = sys.stdout.real
|
|
117
|
+
if isinstance(sys.stderr, StreamInterceptor):
|
|
118
|
+
sys.stderr = sys.stderr.real
|
|
119
|
+
write(MOUSE_OFF)
|
|
120
|
+
_terminal.disable()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def captured_stderr():
|
|
124
|
+
"""Everything written to stderr since enable(), e.g. tracebacks."""
|
|
125
|
+
return "".join(_stderr_log)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def wake():
|
|
129
|
+
"""Make read_inputs() return straight away. Safe from any thread."""
|
|
130
|
+
_terminal.wake()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def read_inputs(timeout=0.1):
|
|
134
|
+
"""Wait up to timeout seconds for input, and return it as a list of Inputs."""
|
|
135
|
+
global _buffer
|
|
136
|
+
_buffer += _terminal.read(timeout)
|
|
137
|
+
|
|
138
|
+
inputs = []
|
|
139
|
+
|
|
140
|
+
# print() does several writes per call, so merge queued text into one input per stream
|
|
141
|
+
while not _text_queue.empty():
|
|
142
|
+
kind, text = _text_queue.get()
|
|
143
|
+
if inputs and inputs[-1].type == kind:
|
|
144
|
+
inputs[-1].details["text"] += text
|
|
145
|
+
else:
|
|
146
|
+
inputs.append(Input(kind, {"text": text}))
|
|
147
|
+
|
|
148
|
+
while _buffer:
|
|
149
|
+
if _buffer.startswith("\x1b[<"):
|
|
150
|
+
match = SGR_MOUSE.match(_buffer)
|
|
151
|
+
if not match:
|
|
152
|
+
break # incomplete mouse report, wait for the rest
|
|
153
|
+
inputs.append(_mouse_input(*match.groups()))
|
|
154
|
+
_buffer = _buffer[match.end():]
|
|
155
|
+
elif _buffer.startswith("\x1b[") or _buffer.startswith("\x1bO"):
|
|
156
|
+
match = CSI.match(_buffer) or SS3.match(_buffer)
|
|
157
|
+
if not match:
|
|
158
|
+
if len(_buffer) < 8:
|
|
159
|
+
break # incomplete, wait for the rest
|
|
160
|
+
match = re.match(r"\x1b.", _buffer) # garbage, skip it
|
|
161
|
+
inputs.append(_sequence_key(match))
|
|
162
|
+
_buffer = _buffer[match.end():]
|
|
163
|
+
elif _buffer.startswith("\x1b") and len(_buffer) > 1:
|
|
164
|
+
inputs.append(_key("alt+" + _char_name(_buffer[1])))
|
|
165
|
+
_buffer = _buffer[2:]
|
|
166
|
+
else:
|
|
167
|
+
inputs.append(_key(_char_name(_buffer[0]), _buffer[0]))
|
|
168
|
+
_buffer = _buffer[1:]
|
|
169
|
+
return inputs
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _key(name, char=None):
|
|
173
|
+
"""A key input. char is the printable character typed, if any."""
|
|
174
|
+
printable = char is not None and (char == " " or char.isprintable())
|
|
175
|
+
return Input("key", {"key": name, "char": char if printable else None})
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _char_name(char):
|
|
179
|
+
if char in CHAR_NAMES:
|
|
180
|
+
return CHAR_NAMES[char]
|
|
181
|
+
if "\x01" <= char <= "\x1a":
|
|
182
|
+
return "ctrl+" + chr(ord(char) + 96)
|
|
183
|
+
return char
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _sequence_key(match):
|
|
187
|
+
groups = match.groups()
|
|
188
|
+
if len(groups) == 1: # ESC O x
|
|
189
|
+
return _key(KEY_NAMES.get(groups[0], match.group()))
|
|
190
|
+
if len(groups) == 0: # skipped garbage
|
|
191
|
+
return _key(match.group())
|
|
192
|
+
params, final = groups
|
|
193
|
+
parts = params.split(";") if params else []
|
|
194
|
+
if final == "~":
|
|
195
|
+
name = KEY_NAMES.get((parts[0] if parts else "") + "~", match.group())
|
|
196
|
+
else:
|
|
197
|
+
name = KEY_NAMES.get(final, match.group())
|
|
198
|
+
if len(parts) == 2 and parts[1] in MODIFIERS: # e.g. ESC [1;5A is ctrl+up
|
|
199
|
+
name = MODIFIERS[parts[1]] + "+" + name
|
|
200
|
+
return _key(name)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _mouse_input(button, x, y, final):
|
|
204
|
+
button, x, y = int(button), int(x) - 1, int(y) - 1 # to 0-based screen coords
|
|
205
|
+
details = {"x": x, "y": y}
|
|
206
|
+
if button & 64:
|
|
207
|
+
details["direction"] = "up" if button & 1 == 0 else "down"
|
|
208
|
+
return Input("mouse_scroll", details)
|
|
209
|
+
if button & 32:
|
|
210
|
+
if button & 3 != 3:
|
|
211
|
+
details["button"] = button & 3
|
|
212
|
+
return Input("mouse_move", details)
|
|
213
|
+
details["button"] = button & 3 # 0 left, 1 middle, 2 right
|
|
214
|
+
return Input("mouse_down" if final == "M" else "mouse_up", details)
|
cmdgui/layout.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
# type, type[name], with an optional {b} (border) or {nb} (no border) on the end
|
|
5
|
+
TOKEN = re.compile(r"^([A-Za-z_]\w*)(?:\[([A-Za-z_]\w*)\])?(?:\{(b|nb)\})?$")
|
|
6
|
+
# a size: "5" exactly, "5+" at least, "5-10" between
|
|
7
|
+
SIZE = re.compile(r"^(\d+)(?:(\+)|-(\d+))?$")
|
|
8
|
+
|
|
9
|
+
EMPTY = "."
|
|
10
|
+
SPAN_LEFT = "-"
|
|
11
|
+
SPAN_UP = "|"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LayoutError(ValueError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Slot:
|
|
20
|
+
name: str
|
|
21
|
+
type: str # None when the layout just names a widget that was passed in
|
|
22
|
+
row: int
|
|
23
|
+
col: int
|
|
24
|
+
rowspan: int = 1
|
|
25
|
+
colspan: int = 1
|
|
26
|
+
border: bool = None # True/False from {b}/{nb}, None to use the widget's default
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Layout:
|
|
31
|
+
rows: int
|
|
32
|
+
cols: int
|
|
33
|
+
slots: dict # name -> Slot, in the order they were declared
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_layout(text, types=None, names=()):
|
|
37
|
+
"""Parse a layout string into a Layout.
|
|
38
|
+
|
|
39
|
+
types is an optional collection of known type names; if given, unknown
|
|
40
|
+
types are an error. names are widgets provided by the caller: a bare word
|
|
41
|
+
matching one of them refers to that widget instead of a type."""
|
|
42
|
+
lines = [line.split() for line in text.splitlines() if line.strip()]
|
|
43
|
+
if not lines: raise LayoutError("layout is empty")
|
|
44
|
+
|
|
45
|
+
cols = len(lines[0])
|
|
46
|
+
for r, cells in enumerate(lines):
|
|
47
|
+
if len(cells) != cols:
|
|
48
|
+
raise LayoutError(f"row {r + 1} has {len(cells)} cells, but row 1 has {cols}")
|
|
49
|
+
|
|
50
|
+
slot_types = {} # name -> type, in declaration order
|
|
51
|
+
slot_borders = {} # name -> True/False/None
|
|
52
|
+
grid = [[None] * cols for _ in lines] # name in each cell, None for empty
|
|
53
|
+
|
|
54
|
+
for r, cells in enumerate(lines):
|
|
55
|
+
for c, token in enumerate(cells):
|
|
56
|
+
where = f"row {r + 1}, column {c + 1}"
|
|
57
|
+
|
|
58
|
+
if token == EMPTY: continue
|
|
59
|
+
|
|
60
|
+
if token == SPAN_LEFT:
|
|
61
|
+
if c == 0: raise LayoutError(f"{where}: '-' has nothing to its left")
|
|
62
|
+
if grid[r][c - 1] is None: raise LayoutError(f"{where}: '-' can't extend an empty cell")
|
|
63
|
+
grid[r][c] = grid[r][c - 1]
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if token == SPAN_UP:
|
|
67
|
+
if r == 0: raise LayoutError(f"{where}: '|' has nothing above it")
|
|
68
|
+
if grid[r - 1][c] is None: raise LayoutError(f"{where}: '|' can't extend an empty cell")
|
|
69
|
+
grid[r][c] = grid[r - 1][c]
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
match = TOKEN.match(token)
|
|
73
|
+
if not match:
|
|
74
|
+
raise LayoutError(f"{where}: can't understand '{token}'")
|
|
75
|
+
type_, name, flag = match.groups()
|
|
76
|
+
if name is None and type_ in slot_types: # repeat of an existing name: span
|
|
77
|
+
if flag:
|
|
78
|
+
raise LayoutError(f"{where}: put {{{flag}}} on the first '{type_}' cell, not a repeat")
|
|
79
|
+
grid[r][c] = type_
|
|
80
|
+
continue
|
|
81
|
+
if name is None:
|
|
82
|
+
name = type_
|
|
83
|
+
if name in names:
|
|
84
|
+
type_ = None # a provided widget, no type needed
|
|
85
|
+
elif name in slot_types:
|
|
86
|
+
raise LayoutError(f"{where}: '{name}' is already declared; repeat it as just '{name}' to span")
|
|
87
|
+
|
|
88
|
+
if types is not None and type_ is not None and type_ not in types:
|
|
89
|
+
known = ", ".join(sorted(types))
|
|
90
|
+
raise LayoutError(f"{where}: unknown widget type '{type_}' (known: {known})")
|
|
91
|
+
slot_types[name] = type_
|
|
92
|
+
slot_borders[name] = {"b": True, "nb": False, None: None}[flag]
|
|
93
|
+
grid[r][c] = name
|
|
94
|
+
|
|
95
|
+
return Layout(len(lines), cols, _build_slots(grid, slot_types, slot_borders))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _build_slots(grid, slot_types, slot_borders):
|
|
99
|
+
"""Turn the grid of names into Slots, checking each name covers a rectangle."""
|
|
100
|
+
cells = {} # name -> list of (row, col)
|
|
101
|
+
for r, row in enumerate(grid):
|
|
102
|
+
for c, name in enumerate(row):
|
|
103
|
+
if name is not None:
|
|
104
|
+
cells.setdefault(name, []).append((r, c))
|
|
105
|
+
|
|
106
|
+
slots = {}
|
|
107
|
+
for name, type_ in slot_types.items():
|
|
108
|
+
rows = [r for r, _ in cells[name]]
|
|
109
|
+
cols = [c for _, c in cells[name]]
|
|
110
|
+
top, left = min(rows), min(cols)
|
|
111
|
+
rowspan, colspan = max(rows) - top + 1, max(cols) - left + 1
|
|
112
|
+
if len(cells[name]) != rowspan * colspan:
|
|
113
|
+
hint = f"; to add a second {type_}, give it its own name: {type_}[other_name]" if type_ else ""
|
|
114
|
+
raise LayoutError(f"'{name}' doesn't form a rectangle{hint}")
|
|
115
|
+
slots[name] = Slot(name, type_, top, left, rowspan, colspan, slot_borders[name])
|
|
116
|
+
return slots
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_size(spec):
|
|
120
|
+
"""Turn a size into (min, max), max None for no limit.
|
|
121
|
+
5 or "5" exactly, "5+" at least 5, "5-10" between, None flexible (at least 1)."""
|
|
122
|
+
if spec is None:
|
|
123
|
+
return 1, None
|
|
124
|
+
if isinstance(spec, int):
|
|
125
|
+
return spec, spec
|
|
126
|
+
match = SIZE.match(str(spec).strip())
|
|
127
|
+
if not match:
|
|
128
|
+
raise LayoutError(f"bad size '{spec}' (use e.g. 5, '5+' or '5-10')")
|
|
129
|
+
low, plus, high = match.groups()
|
|
130
|
+
low = int(low)
|
|
131
|
+
if plus:
|
|
132
|
+
return low, None
|
|
133
|
+
if high is not None:
|
|
134
|
+
if int(high) < low:
|
|
135
|
+
raise LayoutError(f"bad size '{spec}': max is smaller than min")
|
|
136
|
+
return low, int(high)
|
|
137
|
+
return low, low
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class Placement:
|
|
142
|
+
rects: dict # name -> (x, y, width, height) of the widget's content
|
|
143
|
+
frames: dict # name -> (x, y, width, height) of the border, for bordered widgets
|
|
144
|
+
min_width: int # smallest screen the layout fits on
|
|
145
|
+
min_height: int
|
|
146
|
+
fits: bool # False if the screen is smaller than that
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def place(layout, width, height, sizes=None, borders=None, outer=False):
|
|
150
|
+
"""Work out where every widget goes on a width x height screen.
|
|
151
|
+
|
|
152
|
+
sizes: name -> (width spec, height spec), see parse_size
|
|
153
|
+
borders: name -> True if the widget has a border
|
|
154
|
+
outer: always leave room for a border around the whole layout (popups)
|
|
155
|
+
|
|
156
|
+
Border lines sit between grid rows/columns and are shared by neighbours,
|
|
157
|
+
so two bordered widgets next to each other have one line between them."""
|
|
158
|
+
sizes = sizes or {}
|
|
159
|
+
borders = borders or {}
|
|
160
|
+
slots = list(layout.slots.values())
|
|
161
|
+
specs = {s.name: [parse_size(spec) for spec in sizes.get(s.name, (None, None))] for s in slots}
|
|
162
|
+
|
|
163
|
+
col_lines = _lines(layout.cols, [(s.col, s.colspan) for s in slots if borders.get(s.name)])
|
|
164
|
+
row_lines = _lines(layout.rows, [(s.row, s.rowspan) for s in slots if borders.get(s.name)])
|
|
165
|
+
if outer:
|
|
166
|
+
col_lines[0] = col_lines[-1] = row_lines[0] = row_lines[-1] = 1
|
|
167
|
+
col_widths, min_width = _size_tracks(width, layout.cols, col_lines,
|
|
168
|
+
[(s.col, s.colspan, *specs[s.name][0]) for s in slots])
|
|
169
|
+
row_heights, min_height = _size_tracks(height, layout.rows, row_lines,
|
|
170
|
+
[(s.row, s.rowspan, *specs[s.name][1]) for s in slots])
|
|
171
|
+
col_x = _starts(col_widths, col_lines)
|
|
172
|
+
row_y = _starts(row_heights, row_lines)
|
|
173
|
+
|
|
174
|
+
rects, frames = {}, {}
|
|
175
|
+
for s in slots:
|
|
176
|
+
x, y = col_x[s.col], row_y[s.row]
|
|
177
|
+
last_col, last_row = s.col + s.colspan - 1, s.row + s.rowspan - 1
|
|
178
|
+
w = col_x[last_col] + col_widths[last_col] - x
|
|
179
|
+
h = row_y[last_row] + row_heights[last_row] - y
|
|
180
|
+
rects[s.name] = (x, y, w, h)
|
|
181
|
+
if borders.get(s.name):
|
|
182
|
+
frames[s.name] = (x - 1, y - 1, w + 2, h + 2)
|
|
183
|
+
return Placement(rects, frames, min_width, min_height, width >= min_width and height >= min_height)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _lines(count, spans):
|
|
187
|
+
"""Thickness (0 or 1) of the border line before each track, plus one after the last."""
|
|
188
|
+
lines = [0] * (count + 1)
|
|
189
|
+
for start, span in spans:
|
|
190
|
+
lines[start] = lines[start + span] = 1
|
|
191
|
+
return lines
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _size_tracks(total, count, lines, items):
|
|
195
|
+
"""Size the rows (or columns). items are (start, span, min, max) per widget.
|
|
196
|
+
Returns the sizes and the minimum total needed."""
|
|
197
|
+
# Each track's range comes from the widgets that sit only in that track
|
|
198
|
+
mins = [0] * count
|
|
199
|
+
maxs = [None] * count
|
|
200
|
+
single = [[] for _ in range(count)]
|
|
201
|
+
for start, span, low, high in items:
|
|
202
|
+
if span == 1:
|
|
203
|
+
single[start].append((low, high))
|
|
204
|
+
for i, ranges in enumerate(single):
|
|
205
|
+
if ranges:
|
|
206
|
+
mins[i] = max(low for low, _ in ranges)
|
|
207
|
+
# A widget with a size limit limits its track; flexible neighbours adapt
|
|
208
|
+
highs = [high for _, high in ranges if high is not None]
|
|
209
|
+
maxs[i] = max(min(highs), mins[i]) if highs else None
|
|
210
|
+
|
|
211
|
+
# Spanning widgets: if their tracks are too small together, grow one of them
|
|
212
|
+
for start, span, low, high in items:
|
|
213
|
+
if span > 1:
|
|
214
|
+
covered = sum(mins[start:start + span]) + sum(lines[start + 1:start + span])
|
|
215
|
+
if covered < low:
|
|
216
|
+
tracks = range(start, start + span)
|
|
217
|
+
grow = next((i for i in reversed(tracks) if maxs[i] is None), tracks[-1])
|
|
218
|
+
mins[grow] += low - covered
|
|
219
|
+
if maxs[grow] is not None:
|
|
220
|
+
maxs[grow] = max(maxs[grow], mins[grow])
|
|
221
|
+
|
|
222
|
+
# Everyone starts at their min, then the rest is shared equally between
|
|
223
|
+
# tracks that can still grow, in rounds, until it runs out or all are full
|
|
224
|
+
sizes = mins[:]
|
|
225
|
+
remaining = total - sum(lines) - sum(sizes)
|
|
226
|
+
while remaining > 0:
|
|
227
|
+
growable = [i for i in range(count) if maxs[i] is None or sizes[i] < maxs[i]]
|
|
228
|
+
if not growable:
|
|
229
|
+
break
|
|
230
|
+
share = remaining // len(growable)
|
|
231
|
+
if share == 0:
|
|
232
|
+
for i in growable[:remaining]:
|
|
233
|
+
sizes[i] += 1
|
|
234
|
+
break
|
|
235
|
+
for i in growable:
|
|
236
|
+
add = share if maxs[i] is None else min(share, maxs[i] - sizes[i])
|
|
237
|
+
sizes[i] += add
|
|
238
|
+
remaining -= add
|
|
239
|
+
return sizes, sum(mins) + sum(lines)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _starts(sizes, lines):
|
|
243
|
+
"""Start position of each track, leaving room for the border lines."""
|
|
244
|
+
starts, pos = [], 0
|
|
245
|
+
for i, size in enumerate(sizes):
|
|
246
|
+
pos += lines[i]
|
|
247
|
+
starts.append(pos)
|
|
248
|
+
pos += size
|
|
249
|
+
return starts
|