nativelayout 1.2.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.
- nativelayout/core/__init__.py +14 -0
- nativelayout/core/css.py +221 -0
- nativelayout/core/differ.py +140 -0
- nativelayout/core/errors.py +25 -0
- nativelayout/core/expander.py +1266 -0
- nativelayout/core/html_parser.py +326 -0
- nativelayout/core/icons.py +82 -0
- nativelayout/core/layout_engine.py +1004 -0
- nativelayout/core/layout_host.py +248 -0
- nativelayout/core/program.py +290 -0
- nativelayout/core/scopes.py +52 -0
- nativelayout/core/tree.py +406 -0
- nativelayout/host/__init__.py +4 -0
- nativelayout/host/draw.py +189 -0
- nativelayout/host/flex.py +365 -0
- nativelayout/host/kivy_host.py +619 -0
- nativelayout/host/native_view.py +86 -0
- nativelayout/host/widgets.py +334 -0
- nativelayout-1.2.0.dist-info/METADATA +93 -0
- nativelayout-1.2.0.dist-info/RECORD +23 -0
- nativelayout-1.2.0.dist-info/WHEEL +5 -0
- nativelayout-1.2.0.dist-info/licenses/LICENSE +373 -0
- nativelayout-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""HTML parser of the core (port of HtmlParser.kt)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from .errors import NativeLayoutError, extract_line
|
|
8
|
+
from .tree import Native, NativeStyle
|
|
9
|
+
|
|
10
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
11
|
+
_CLASS_CACHE: dict[str, list[str]] = {}
|
|
12
|
+
_CLASS_LOCK = threading.Lock()
|
|
13
|
+
_HEX_RE = re.compile(r"^[0-9a-fA-F]+$")
|
|
14
|
+
_DEC_RE = re.compile(r"^[0-9]+$")
|
|
15
|
+
VOID_TAGS = frozenset({"br", "hr", "img", "input", "meta", "link"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MarkupError(NativeLayoutError):
|
|
19
|
+
def __init__(self, message: str, line: int, source: str,
|
|
20
|
+
word: str | None = None, suggestion: str | None = None):
|
|
21
|
+
super().__init__(f"markup: {message}", line, extract_line(source, line), word, suggestion)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HtmlNode:
|
|
25
|
+
line: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HtmlText(HtmlNode):
|
|
29
|
+
def __init__(self, text: str, line: int):
|
|
30
|
+
self.text = text
|
|
31
|
+
self.line = line
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HtmlElement(HtmlNode):
|
|
35
|
+
def __init__(self, tag: str, attributes: dict[str, str], children: list[HtmlNode], line: int):
|
|
36
|
+
self.tag = tag
|
|
37
|
+
self.attributes = attributes
|
|
38
|
+
self.children = children
|
|
39
|
+
self.line = line
|
|
40
|
+
self.chunk_text: str | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def id(self) -> str | None:
|
|
44
|
+
v = self.attributes.get("id")
|
|
45
|
+
if v is None:
|
|
46
|
+
return None
|
|
47
|
+
v = v.strip()
|
|
48
|
+
return v or None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def classes(self) -> list[str]:
|
|
52
|
+
raw = self.attributes.get("class")
|
|
53
|
+
if raw is None:
|
|
54
|
+
return []
|
|
55
|
+
with _CLASS_LOCK:
|
|
56
|
+
hit = _CLASS_CACHE.get(raw)
|
|
57
|
+
if hit is not None:
|
|
58
|
+
return hit
|
|
59
|
+
parsed = [p for p in _WHITESPACE.split(raw) if p]
|
|
60
|
+
if len(_CLASS_CACHE) > 1024:
|
|
61
|
+
_CLASS_CACHE.clear()
|
|
62
|
+
_CLASS_CACHE[raw] = parsed
|
|
63
|
+
return parsed
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Chunk:
|
|
67
|
+
"""An element with a key whose text did not change: it is not parsed again."""
|
|
68
|
+
def __init__(self, text: str, element: HtmlElement, native: Native,
|
|
69
|
+
parent_style: NativeStyle, ids: list[tuple[str, Native]]):
|
|
70
|
+
self.text = text
|
|
71
|
+
self.element = element
|
|
72
|
+
self.native = native
|
|
73
|
+
self.parent_style = parent_style
|
|
74
|
+
self.ids = ids
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class HtmlCachedElement(HtmlElement):
|
|
78
|
+
def __init__(self, chunk: Chunk, line: int):
|
|
79
|
+
super().__init__(chunk.element.tag, chunk.element.attributes,
|
|
80
|
+
chunk.element.children, line)
|
|
81
|
+
self.chunk = chunk
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ChunkCache:
|
|
85
|
+
def __init__(self):
|
|
86
|
+
self._map: dict[str, Chunk] = {}
|
|
87
|
+
self._variables: dict[str, str] | None = None
|
|
88
|
+
self._lock = threading.RLock()
|
|
89
|
+
|
|
90
|
+
def use_for(self, variables: dict[str, str]) -> None:
|
|
91
|
+
with self._lock:
|
|
92
|
+
if self._variables != variables:
|
|
93
|
+
self._map.clear()
|
|
94
|
+
self._variables = variables
|
|
95
|
+
|
|
96
|
+
def get(self, key: str) -> Chunk | None:
|
|
97
|
+
with self._lock:
|
|
98
|
+
return self._map.get(key)
|
|
99
|
+
|
|
100
|
+
def put(self, key: str, chunk: Chunk) -> None:
|
|
101
|
+
with self._lock:
|
|
102
|
+
if len(self._map) >= 20000:
|
|
103
|
+
self._map.clear()
|
|
104
|
+
self._map[key] = chunk
|
|
105
|
+
|
|
106
|
+
def clear(self) -> None:
|
|
107
|
+
with self._lock:
|
|
108
|
+
self._map.clear()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _Open:
|
|
112
|
+
def __init__(self, tag: str, attributes: dict[str, str], line: int, start: int):
|
|
113
|
+
self.tag = tag
|
|
114
|
+
self.attributes = attributes
|
|
115
|
+
self.line = line
|
|
116
|
+
self.start = start
|
|
117
|
+
self.children: list[HtmlNode] = []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _key_of(attributes: dict[str, str]) -> str | None:
|
|
121
|
+
v = attributes.get("key")
|
|
122
|
+
if v is None:
|
|
123
|
+
return None
|
|
124
|
+
v = v.strip()
|
|
125
|
+
return v or None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class HtmlParser:
|
|
129
|
+
def __init__(self, html: str, start_line: int, source: str,
|
|
130
|
+
chunks: ChunkCache | None = None):
|
|
131
|
+
self.html = html
|
|
132
|
+
self.source = source
|
|
133
|
+
self.chunks = chunks
|
|
134
|
+
self.i = 0
|
|
135
|
+
self.line = start_line
|
|
136
|
+
self.stack: list[_Open] = []
|
|
137
|
+
self.root: list[HtmlNode] = []
|
|
138
|
+
|
|
139
|
+
def _err(self, message: str, at: int | None = None, word: str | None = None):
|
|
140
|
+
return MarkupError(message, self.line if at is None else at, self.source, word)
|
|
141
|
+
|
|
142
|
+
def _target(self) -> list[HtmlNode]:
|
|
143
|
+
return self.root if not self.stack else self.stack[-1].children
|
|
144
|
+
|
|
145
|
+
def _advance_to(self, end: int) -> None:
|
|
146
|
+
self.line += self.html.count("\n", self.i, end)
|
|
147
|
+
self.i = end
|
|
148
|
+
|
|
149
|
+
def parse(self) -> list[HtmlNode]:
|
|
150
|
+
html = self.html
|
|
151
|
+
n = len(html)
|
|
152
|
+
while self.i < n:
|
|
153
|
+
i = self.i
|
|
154
|
+
c = html[i]
|
|
155
|
+
if html.startswith("<!--", i):
|
|
156
|
+
end = html.find("-->", i + 4)
|
|
157
|
+
if end < 0:
|
|
158
|
+
raise self._err("unclosed HTML comment: missing '-->'")
|
|
159
|
+
self._advance_to(end + 3)
|
|
160
|
+
elif html.startswith("<!", i):
|
|
161
|
+
end = html.find(">", i)
|
|
162
|
+
if end < 0:
|
|
163
|
+
raise self._err("unclosed '<!': missing '>'")
|
|
164
|
+
self._advance_to(end + 1)
|
|
165
|
+
elif html.startswith("</", i):
|
|
166
|
+
self._read_close()
|
|
167
|
+
elif c == "<" and i + 1 < n and html[i + 1].isalpha():
|
|
168
|
+
self._read_open()
|
|
169
|
+
else:
|
|
170
|
+
self._read_text()
|
|
171
|
+
if self.stack:
|
|
172
|
+
a = self.stack[-1]
|
|
173
|
+
raise self._err(f"<{a.tag}> is never closed (opened on line {a.line})", a.line, f"<{a.tag}")
|
|
174
|
+
return self.root
|
|
175
|
+
|
|
176
|
+
def _read_text(self) -> None:
|
|
177
|
+
html = self.html
|
|
178
|
+
n = len(html)
|
|
179
|
+
j = self.i + 1
|
|
180
|
+
while j < n:
|
|
181
|
+
if html[j] == "<" and j + 1 < n and (html[j + 1].isalpha() or html[j + 1] in "/!"):
|
|
182
|
+
break
|
|
183
|
+
j += 1
|
|
184
|
+
raw = html[self.i:j]
|
|
185
|
+
l = self.line
|
|
186
|
+
self._advance_to(j)
|
|
187
|
+
if raw:
|
|
188
|
+
self._target().append(HtmlText(self._decode(raw), l))
|
|
189
|
+
|
|
190
|
+
def _read_close(self) -> None:
|
|
191
|
+
tag_line = self.line
|
|
192
|
+
end = self.html.find(">", self.i)
|
|
193
|
+
if end < 0:
|
|
194
|
+
raise self._err("unclosed '</': missing '>'")
|
|
195
|
+
tag = self.html[self.i + 2:end].strip().lower()
|
|
196
|
+
self._advance_to(end + 1)
|
|
197
|
+
if tag in VOID_TAGS:
|
|
198
|
+
return
|
|
199
|
+
if not self.stack:
|
|
200
|
+
raise self._err(f"</{tag}> has no matching opening tag", tag_line, f"</{tag}")
|
|
201
|
+
top = self.stack[-1]
|
|
202
|
+
if top.tag != tag:
|
|
203
|
+
raise self._err(f"expected </{top.tag}> (opened on line {top.line}) before </{tag}>",
|
|
204
|
+
tag_line, f"</{tag}")
|
|
205
|
+
self.stack.pop()
|
|
206
|
+
element = HtmlElement(top.tag, top.attributes, list(top.children), top.line)
|
|
207
|
+
if self.chunks is not None and _key_of(top.attributes) is not None:
|
|
208
|
+
element.chunk_text = self.html[top.start:end + 1]
|
|
209
|
+
self._target().append(element)
|
|
210
|
+
|
|
211
|
+
def _read_open(self) -> None:
|
|
212
|
+
html = self.html
|
|
213
|
+
n = len(html)
|
|
214
|
+
tag_start = self.i
|
|
215
|
+
tag_line = self.line
|
|
216
|
+
j = self.i + 1
|
|
217
|
+
name_start = j
|
|
218
|
+
while j < n and (html[j].isalnum() or html[j] == "-"):
|
|
219
|
+
j += 1
|
|
220
|
+
tag = html[name_start:j].lower()
|
|
221
|
+
attributes: dict[str, str] = {}
|
|
222
|
+
self_close = False
|
|
223
|
+
|
|
224
|
+
while True:
|
|
225
|
+
while j < n and html[j].isspace():
|
|
226
|
+
j += 1
|
|
227
|
+
if j >= n:
|
|
228
|
+
raise self._err(f"unclosed tag <{tag}>: missing '>'", tag_line, f"<{tag}")
|
|
229
|
+
ch = html[j]
|
|
230
|
+
if ch == ">":
|
|
231
|
+
j += 1
|
|
232
|
+
break
|
|
233
|
+
if ch == "/" and j + 1 < n and html[j + 1] == ">":
|
|
234
|
+
self_close = True
|
|
235
|
+
j += 2
|
|
236
|
+
break
|
|
237
|
+
if ch == "/":
|
|
238
|
+
j += 1
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
attr_start = j
|
|
242
|
+
while j < n and not html[j].isspace() and html[j] not in "=>/":
|
|
243
|
+
j += 1
|
|
244
|
+
if j == attr_start:
|
|
245
|
+
raise self._err(f"invalid attribute in tag <{tag}>", tag_line, f"<{tag}")
|
|
246
|
+
name = html[attr_start:j].lower()
|
|
247
|
+
while j < n and html[j].isspace():
|
|
248
|
+
j += 1
|
|
249
|
+
|
|
250
|
+
value = ""
|
|
251
|
+
if j < n and html[j] == "=":
|
|
252
|
+
j += 1
|
|
253
|
+
while j < n and html[j].isspace():
|
|
254
|
+
j += 1
|
|
255
|
+
if j < n and html[j] in "\"'":
|
|
256
|
+
quote = html[j]
|
|
257
|
+
end = html.find(quote, j + 1)
|
|
258
|
+
if end < 0:
|
|
259
|
+
raise self._err(f"attribute '{name}' has an unclosed quote", tag_line, name)
|
|
260
|
+
value = html[j + 1:end]
|
|
261
|
+
j = end + 1
|
|
262
|
+
else:
|
|
263
|
+
val_start = j
|
|
264
|
+
while j < n and not html[j].isspace() and html[j] != ">":
|
|
265
|
+
j += 1
|
|
266
|
+
value = html[val_start:j]
|
|
267
|
+
if name not in attributes:
|
|
268
|
+
attributes[name] = self._decode(value)
|
|
269
|
+
self._advance_to(j)
|
|
270
|
+
|
|
271
|
+
if tag == "script":
|
|
272
|
+
raise self._err("<script> is not allowed", tag_line, "<script")
|
|
273
|
+
if tag == "style":
|
|
274
|
+
raise self._err('<style> is not supported yet; use style="..."', tag_line, "<style")
|
|
275
|
+
if tag in VOID_TAGS or self_close:
|
|
276
|
+
self._target().append(HtmlElement(tag, attributes, [], tag_line))
|
|
277
|
+
return
|
|
278
|
+
key = _key_of(attributes) if self.chunks is not None else None
|
|
279
|
+
hit = self.chunks.get(tag + "|" + key) if key is not None else None
|
|
280
|
+
if hit is not None and html.startswith(hit.text, tag_start):
|
|
281
|
+
self._advance_to(tag_start + len(hit.text))
|
|
282
|
+
self._target().append(HtmlCachedElement(hit, tag_line))
|
|
283
|
+
else:
|
|
284
|
+
self.stack.append(_Open(tag, attributes, tag_line, tag_start))
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _decode(t: str) -> str:
|
|
288
|
+
if "&" not in t:
|
|
289
|
+
return t
|
|
290
|
+
out: list[str] = []
|
|
291
|
+
k = 0
|
|
292
|
+
n = len(t)
|
|
293
|
+
while k < n:
|
|
294
|
+
c = t[k]
|
|
295
|
+
if c == "&":
|
|
296
|
+
end = t.find(";", k)
|
|
297
|
+
if k + 2 <= end <= k + 10:
|
|
298
|
+
name = t[k + 1:end]
|
|
299
|
+
r: str | None = None
|
|
300
|
+
if name == "amp":
|
|
301
|
+
r = "&"
|
|
302
|
+
elif name == "lt":
|
|
303
|
+
r = "<"
|
|
304
|
+
elif name == "gt":
|
|
305
|
+
r = ">"
|
|
306
|
+
elif name == "quot":
|
|
307
|
+
r = '"'
|
|
308
|
+
elif name == "apos":
|
|
309
|
+
r = "'"
|
|
310
|
+
elif name == "nbsp":
|
|
311
|
+
r = "\u00A0"
|
|
312
|
+
elif name[:2] in ("#x", "#X"):
|
|
313
|
+
d = name[2:]
|
|
314
|
+
if _HEX_RE.match(d) and int(d, 16) <= 0x10FFFF:
|
|
315
|
+
r = chr(int(d, 16))
|
|
316
|
+
elif name.startswith("#"):
|
|
317
|
+
d = name[1:]
|
|
318
|
+
if _DEC_RE.match(d) and int(d) <= 0x10FFFF:
|
|
319
|
+
r = chr(int(d))
|
|
320
|
+
if r is not None:
|
|
321
|
+
out.append(r)
|
|
322
|
+
k = end + 1
|
|
323
|
+
continue
|
|
324
|
+
out.append(c)
|
|
325
|
+
k += 1
|
|
326
|
+
return "".join(out)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Built-in icons (port of IconSet.kt). 24x24 grid, stroke 2, round caps."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
PREFIX = "icon:"
|
|
6
|
+
GRID = 24.0
|
|
7
|
+
STROKE = 2.0
|
|
8
|
+
DOT_RADIUS = 1.6
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Line:
|
|
13
|
+
points: tuple[float, ...]
|
|
14
|
+
closed: bool = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Circle:
|
|
19
|
+
cx: float
|
|
20
|
+
cy: float
|
|
21
|
+
r: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Dot:
|
|
26
|
+
cx: float
|
|
27
|
+
cy: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Rect:
|
|
32
|
+
left: float
|
|
33
|
+
top: float
|
|
34
|
+
right: float
|
|
35
|
+
bottom: float
|
|
36
|
+
radius: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_SHAPES: dict[str, list] = {
|
|
40
|
+
"arrow-left": [Line((19, 12, 5, 12,)), Line((12, 19, 5, 12, 12, 5,))],
|
|
41
|
+
"arrow-right": [Line((5, 12, 19, 12,)), Line((12, 5, 19, 12, 12, 19,))],
|
|
42
|
+
"arrow-up": [Line((12, 19, 12, 5,)), Line((5, 12, 12, 5, 19, 12,))],
|
|
43
|
+
"arrow-down": [Line((12, 5, 12, 19,)), Line((5, 12, 12, 19, 19, 12,))],
|
|
44
|
+
"chevron-left": [Line((15, 5, 8, 12, 15, 19,))],
|
|
45
|
+
"chevron-right": [Line((9, 5, 16, 12, 9, 19,))],
|
|
46
|
+
"chevron-up": [Line((5, 15, 12, 8, 19, 15,))],
|
|
47
|
+
"chevron-down": [Line((5, 9, 12, 16, 19, 9,))],
|
|
48
|
+
"close": [Line((6, 6, 18, 18,)), Line((18, 6, 6, 18,))],
|
|
49
|
+
"check": [Line((4, 12.5, 9.5, 18, 20, 6.5,))],
|
|
50
|
+
"plus": [Line((12, 5, 12, 19,)), Line((5, 12, 19, 12,))],
|
|
51
|
+
"minus": [Line((5, 12, 19, 12,))],
|
|
52
|
+
"menu": [Line((4, 6, 20, 6,)), Line((4, 12, 20, 12,)), Line((4, 18, 20, 18,))],
|
|
53
|
+
"search": [Circle(10.5, 10.5, 6.5), Line((15.3, 15.3, 20, 20,))],
|
|
54
|
+
"more-vertical": [Dot(12, 5), Dot(12, 12), Dot(12, 19)],
|
|
55
|
+
"more-horizontal": [Dot(5, 12), Dot(12, 12), Dot(19, 12)],
|
|
56
|
+
"home": [Line((3, 11, 12, 3, 21, 11,)), Line((5, 9.5, 5, 21, 19, 21, 19, 9.5,)), Line((10, 21, 10, 15, 14, 15, 14, 21,))],
|
|
57
|
+
"user": [Circle(12, 8, 4), Line((5, 21, 5, 19.5, 5.8, 17, 7.6, 15.3, 10, 14.3, 12, 14, 14, 14.3, 16.4, 15.3, 18.2, 17, 19, 19.5, 19, 21,))],
|
|
58
|
+
"trash": [Line((4, 7, 20, 7,)), Line((9, 7, 9, 4, 15, 4, 15, 7,)), Line((6, 7, 7, 21, 17, 21, 18, 7,)), Line((10, 11, 10, 17,)), Line((14, 11, 14, 17,))],
|
|
59
|
+
"edit": [Line((4, 20, 5, 15, 16, 4, 20, 8, 9, 19,), True), Line((14, 6, 18, 10,))],
|
|
60
|
+
"star": [Line((12, 3, 14.4, 9.3, 21.1, 9.6, 15.9, 13.9, 17.6, 20.4, 12, 16.7, 6.4, 20.4, 8.1, 13.9, 2.9, 9.6, 9.6, 9.3,), True)],
|
|
61
|
+
"lock": [Rect(5, 11, 19, 21, 2), Line((8, 11, 8, 8.2, 8.6, 6.4, 10, 5.1, 12, 4.6, 14, 5.1, 15.4, 6.4, 16, 8.2, 16, 11,))],
|
|
62
|
+
"eye": [Line((2, 12, 4, 9.6, 7, 7.6, 9.5, 6.6, 12, 6.3, 14.5, 6.6, 17, 7.6, 20, 9.6, 22, 12, 20, 14.4, 17, 16.4, 14.5, 17.4, 12, 17.7, 9.5, 17.4, 7, 16.4, 4, 14.4,), True), Circle(12, 12, 3)],
|
|
63
|
+
"mail": [Rect(3, 5, 21, 19, 2), Line((3, 7, 12, 13, 21, 7,))],
|
|
64
|
+
"info": [Circle(12, 12, 9), Line((12, 11, 12, 17,)), Dot(12, 7.5)],
|
|
65
|
+
"alert": [Line((12, 3, 22, 20, 2, 20,), True), Line((12, 9, 12, 14,)), Dot(12, 17)],
|
|
66
|
+
"calendar": [Rect(3, 5, 21, 21, 2), Line((3, 10, 21, 10,)), Line((8, 3, 8, 7,)), Line((16, 3, 16, 7,))],
|
|
67
|
+
"logout": [Line((9, 4, 5, 4, 5, 20, 9, 20,)), Line((10, 12, 20, 12,)), Line((16, 8, 20, 12, 16, 16,))],
|
|
68
|
+
"bookmark": [Line((6, 3, 18, 3, 18, 21, 12, 16, 6, 21,), True)],
|
|
69
|
+
"clock": [Circle(12, 12, 9), Line((12, 7, 12, 12, 16, 14,))],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def name_of(source: str) -> str | None:
|
|
74
|
+
return source[len(PREFIX):].strip() if source.startswith(PREFIX) else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def shapes_of(name: str):
|
|
78
|
+
return _SHAPES.get(name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def names() -> list[str]:
|
|
82
|
+
return list(_SHAPES.keys())
|