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,1266 @@
|
|
|
1
|
+
"""Expander: `.nl` -> plain HTML with {value}, if/for/include and styles (port of Expander.kt)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
|
|
10
|
+
from . import scopes as Scopes
|
|
11
|
+
from .errors import NativeLayoutError, extract_line
|
|
12
|
+
from .layout_engine import LayoutWarning
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ExpandedScreen:
|
|
17
|
+
html: str
|
|
18
|
+
line_map: list[int]
|
|
19
|
+
warnings: list[LayoutWarning] = field(default_factory=list)
|
|
20
|
+
# The item of each repetition of a `for` that has an id or a class, by path (`p:7`, `p:7/o:2`).
|
|
21
|
+
items: dict[str, object] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExpandError(NativeLayoutError):
|
|
25
|
+
def __init__(self, message: str, line: int, source: str,
|
|
26
|
+
word: str | None = None, suggestion: str | None = None):
|
|
27
|
+
super().__init__(message, line, extract_line(source, line), word, suggestion)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _rx(p: str) -> re.Pattern:
|
|
31
|
+
return re.compile(p, re.ASCII)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
KIND_TAG, KIND_CLASS, KIND_ID = 1, 2, 3
|
|
35
|
+
STATE_NONE, STATE_PRESSED, STATE_DISABLED, STATE_FOCUS = 0, 1, 2, 3
|
|
36
|
+
STATE_ATTRIBUTES = ("style-pressed", "style-disabled", "style-focus")
|
|
37
|
+
MAX_INCLUDE_LEVELS = 8
|
|
38
|
+
VOID_NAMES = frozenset({"br", "hr", "img", "input", "meta", "link"})
|
|
39
|
+
|
|
40
|
+
INCLUDE_START = _rx(r"^<include(?=[\s/>])")
|
|
41
|
+
KEY_ATTR = _rx(r'\skey\s*=\s*"([^"]*)"')
|
|
42
|
+
HAS_KEY = _rx(r"\skey\s*=")
|
|
43
|
+
FIELD_OR_ID = _rx(r"<(?:input|textarea)\b|\sid\s*=")
|
|
44
|
+
TAG_RE = _rx(r"<[^<>]*>")
|
|
45
|
+
TAG_NAME_RE = _rx(r"^<([A-Za-z][A-Za-z0-9-]*)")
|
|
46
|
+
ATTR_RE = _rx(r"""([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?""")
|
|
47
|
+
BRACE_RE = _rx(r"\\[{}]|\{([^{}]*)\}")
|
|
48
|
+
NAME_RE = _rx(r"[A-Za-z_][A-Za-z0-9_.]*")
|
|
49
|
+
SINGLE_VALUE_RE = _rx(r"\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}")
|
|
50
|
+
VAR_RE = _rx(r"var\(\s*(--[A-Za-z0-9_-]+)\s*\)")
|
|
51
|
+
RULE_RE = _rx(r"([^{}]+)\{([^{}]*)\}")
|
|
52
|
+
COMMENT_RE = _rx(r"/\*[\s\S]*?\*/")
|
|
53
|
+
FOR_RE = _rx(r"for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([A-Za-z_][A-Za-z0-9_.]*):")
|
|
54
|
+
KEYWORD_RE = _rx(r"^(if|elif|for)\s")
|
|
55
|
+
ELSE_SPACE_RE = _rx(r"else\s+:")
|
|
56
|
+
TOKEN_RE = _rx(r'==|!=|<=|>=|<|>|\(|\)|"[^"]*"|-?\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_.]*')
|
|
57
|
+
TAG_SELECTOR = _rx(r"[A-Za-z][A-Za-z0-9]*")
|
|
58
|
+
CLASS_SELECTOR = _rx(r"\.[A-Za-z_][A-Za-z0-9_-]*")
|
|
59
|
+
ID_SELECTOR = _rx(r"#[A-Za-z_][A-Za-z0-9_-]*")
|
|
60
|
+
COMPARATORS = frozenset({"==", "!=", "<", ">", "<=", ">="})
|
|
61
|
+
WS_RE = _rx(r"\s+")
|
|
62
|
+
|
|
63
|
+
_MISSING = object()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _first(m: re.Match, *idx: int) -> str | None:
|
|
67
|
+
for i in idx:
|
|
68
|
+
g = m.group(i)
|
|
69
|
+
if g is not None:
|
|
70
|
+
return g
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------- model
|
|
75
|
+
class Rule:
|
|
76
|
+
def __init__(self, kind, name, declarations, order, line, file, link_line, state=STATE_NONE):
|
|
77
|
+
self.kind = kind
|
|
78
|
+
self.name = name
|
|
79
|
+
self.declarations = declarations
|
|
80
|
+
self.order = order
|
|
81
|
+
self.line = line
|
|
82
|
+
self.file = file
|
|
83
|
+
self.link_line = link_line
|
|
84
|
+
self.state = state
|
|
85
|
+
self.resolved: list[tuple[str, str]] = []
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Attr:
|
|
89
|
+
__slots__ = ("key", "value")
|
|
90
|
+
|
|
91
|
+
def __init__(self, key: str, value: str | None):
|
|
92
|
+
self.key = key
|
|
93
|
+
self.value = value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Scope:
|
|
97
|
+
def __init__(self, parent: "Scope | None", vars_: dict, path: str | None = None):
|
|
98
|
+
self.parent = parent
|
|
99
|
+
self.vars = vars_
|
|
100
|
+
self.path = path if path is not None else (parent.path if parent is not None else "")
|
|
101
|
+
|
|
102
|
+
def find(self, name: str):
|
|
103
|
+
s = self
|
|
104
|
+
while s is not None:
|
|
105
|
+
if name in s.vars:
|
|
106
|
+
return s.vars[name]
|
|
107
|
+
s = s.parent
|
|
108
|
+
return _MISSING
|
|
109
|
+
|
|
110
|
+
def has(self, name: str) -> bool:
|
|
111
|
+
return name in self.vars or (self.parent.has(name) if self.parent is not None else False)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Node:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TextNode(Node):
|
|
119
|
+
def __init__(self, text: str, line: int):
|
|
120
|
+
self.text = text
|
|
121
|
+
self.line = line
|
|
122
|
+
self.parts: list | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Branch:
|
|
126
|
+
def __init__(self, condition: str | None, line: int):
|
|
127
|
+
self.condition = condition
|
|
128
|
+
self.line = line
|
|
129
|
+
self.nodes: list[Node] = []
|
|
130
|
+
self.tokens: list[str] | None = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class IfNode(Node):
|
|
134
|
+
def __init__(self, line: int):
|
|
135
|
+
self.line = line
|
|
136
|
+
self.branches: list[Branch] = []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ForNode(Node):
|
|
140
|
+
def __init__(self, line: int, variable: str, list_name: str):
|
|
141
|
+
self.line = line
|
|
142
|
+
self.variable = variable
|
|
143
|
+
self.list_name = list_name
|
|
144
|
+
self.nodes: list[Node] = []
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ParamPlan:
|
|
148
|
+
def __init__(self, name, constant, single, parts):
|
|
149
|
+
self.name = name
|
|
150
|
+
self.constant = constant
|
|
151
|
+
self.single = single
|
|
152
|
+
self.parts = parts
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class IncludeNode(Node):
|
|
156
|
+
def __init__(self, line, component, params, key):
|
|
157
|
+
self.line = line
|
|
158
|
+
self.component = component
|
|
159
|
+
self.params = params
|
|
160
|
+
self.key = key
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Frame:
|
|
164
|
+
def __init__(self, open_line: int, nodes: list, if_node: IfNode | None):
|
|
165
|
+
self.open_line = open_line
|
|
166
|
+
self.nodes = nodes
|
|
167
|
+
self.if_node = if_node
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Control:
|
|
171
|
+
def __init__(self, type_: str, argument: str, variable: str = ""):
|
|
172
|
+
self.type = type_
|
|
173
|
+
self.argument = argument
|
|
174
|
+
self.variable = variable
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class FileLayout:
|
|
179
|
+
links: list[tuple[str, int]]
|
|
180
|
+
style_start: int
|
|
181
|
+
style_end: int
|
|
182
|
+
body_start: int
|
|
183
|
+
body_end: int
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class Component:
|
|
187
|
+
def __init__(self, name, source, lines, layout, include_line):
|
|
188
|
+
self.name = name
|
|
189
|
+
self.source = source
|
|
190
|
+
self.lines = lines
|
|
191
|
+
self.layout = layout
|
|
192
|
+
self.include_line = include_line
|
|
193
|
+
self.nodes: list[Node] = []
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class IncludeTag:
|
|
197
|
+
def __init__(self, attrs: list[tuple[str, str | None]]):
|
|
198
|
+
self.attrs = attrs
|
|
199
|
+
|
|
200
|
+
def get(self, name: str) -> str | None:
|
|
201
|
+
n = name.lower()
|
|
202
|
+
for k, v in self.attrs:
|
|
203
|
+
if k.lower() == n:
|
|
204
|
+
return v
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class Part:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class Lit(Part):
|
|
213
|
+
def __init__(self, s: str):
|
|
214
|
+
self.s = s
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class Val(Part):
|
|
218
|
+
def __init__(self, inner: str):
|
|
219
|
+
self.inner = inner
|
|
220
|
+
self.name = inner.strip()
|
|
221
|
+
self.ok = NAME_RE.fullmatch(self.name) is not None
|
|
222
|
+
self.path = self.name.split(".")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class AttrPlan:
|
|
226
|
+
def __init__(self, orig, key, value, brace, single, text_parts):
|
|
227
|
+
self.orig = orig
|
|
228
|
+
self.key = key
|
|
229
|
+
self.value = value
|
|
230
|
+
self.brace = brace
|
|
231
|
+
self.single = single
|
|
232
|
+
self.text_parts = text_parts
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class DynTag(Part):
|
|
236
|
+
def __init__(self, name, lower, self_close, rule_style, attrs, state_styles):
|
|
237
|
+
self.name = name
|
|
238
|
+
self.lower = lower
|
|
239
|
+
self.self_close = self_close
|
|
240
|
+
self.rule_style = rule_style
|
|
241
|
+
self.attrs = attrs
|
|
242
|
+
self.state_styles = state_styles
|
|
243
|
+
self.pre: str | None = None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class Prepared:
|
|
247
|
+
def __init__(self, rules, nodes):
|
|
248
|
+
self.rules = rules
|
|
249
|
+
self.nodes = nodes
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------- cache
|
|
253
|
+
_cache: dict[str, Prepared] = {}
|
|
254
|
+
_cache_lock = threading.Lock()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class Expander:
|
|
258
|
+
@staticmethod
|
|
259
|
+
def expand(source: str, data: dict, resolve=None, dark: bool = False) -> ExpandedScreen:
|
|
260
|
+
return _ScreenExpander(source, data, resolve or (lambda name: None), dark).expand()
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def clear_cache() -> None:
|
|
264
|
+
with _cache_lock:
|
|
265
|
+
_cache.clear()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _cached(key: str):
|
|
269
|
+
with _cache_lock:
|
|
270
|
+
return _cache.get(key)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _store(key: str, prepared: Prepared) -> None:
|
|
274
|
+
with _cache_lock:
|
|
275
|
+
if len(_cache) >= 32:
|
|
276
|
+
_cache.clear()
|
|
277
|
+
_cache[key] = prepared
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------- utilities
|
|
281
|
+
def _escape_html(s: str) -> str:
|
|
282
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _is_num(v) -> bool:
|
|
286
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _format_number(n) -> str:
|
|
290
|
+
if isinstance(n, float):
|
|
291
|
+
if math.isfinite(n) and n == math.floor(n) and abs(n) < 9.2e18:
|
|
292
|
+
return str(int(n))
|
|
293
|
+
if not math.isfinite(n):
|
|
294
|
+
return "NaN" if math.isnan(n) else ("Infinity" if n > 0 else "-Infinity")
|
|
295
|
+
a = abs(n)
|
|
296
|
+
if a >= 1e7 or (a < 1e-3 and a != 0.0):
|
|
297
|
+
sign, digits, exp = Decimal(repr(n)).normalize().as_tuple()
|
|
298
|
+
ds = "".join(map(str, digits))
|
|
299
|
+
e10 = exp + len(ds) - 1
|
|
300
|
+
return ("-" if sign else "") + ds[0] + "." + (ds[1:] or "0") + "E" + str(e10)
|
|
301
|
+
return repr(n)
|
|
302
|
+
return str(n)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _singular(name: str) -> str:
|
|
306
|
+
last = name.rpartition(".")[2]
|
|
307
|
+
return last[:-1] if len(last) > 1 and last.endswith("s") else "item"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _equals(a, b) -> bool:
|
|
311
|
+
if isinstance(a, bool) != isinstance(b, bool):
|
|
312
|
+
return False
|
|
313
|
+
return a == b
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _join_path(frm: str, rel: str) -> str:
|
|
317
|
+
d = frm.rpartition("/")[0] if "/" in frm else ""
|
|
318
|
+
return rel if not d else f"{d}/{rel}"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _merge_lits(lst: list[Part]) -> list[Part]:
|
|
322
|
+
out: list[Part] = []
|
|
323
|
+
for p in lst:
|
|
324
|
+
if isinstance(p, Lit) and out and isinstance(out[-1], Lit):
|
|
325
|
+
out[-1] = Lit(out[-1].s + p.s)
|
|
326
|
+
else:
|
|
327
|
+
out.append(p)
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ---------------------------------------------------------------- expander
|
|
332
|
+
class _ScreenExpander:
|
|
333
|
+
def __init__(self, source: str, data: dict, resolve, dark: bool):
|
|
334
|
+
self.source = source
|
|
335
|
+
self.data = data
|
|
336
|
+
self.resolve = resolve
|
|
337
|
+
self.dark = dark
|
|
338
|
+
self.lines = source.split("\n")
|
|
339
|
+
self.rules: list[Rule] = []
|
|
340
|
+
self.variables: dict[str, str] = {}
|
|
341
|
+
self.rule_order = 0
|
|
342
|
+
self.out: list[str] = []
|
|
343
|
+
self.out_lines: list[int] = []
|
|
344
|
+
self.warnings: list[LayoutWarning] = []
|
|
345
|
+
self.current_component: str | None = None
|
|
346
|
+
self.current_include_line = 0
|
|
347
|
+
self.components: dict[str, Component] = {}
|
|
348
|
+
self.scope_counter = 0
|
|
349
|
+
self.marker_hits = 0
|
|
350
|
+
self.auto_keyed: set[int] = set()
|
|
351
|
+
self.scope_log: list[tuple[str, object]] = []
|
|
352
|
+
|
|
353
|
+
# -- errors
|
|
354
|
+
def err(self, message: str, line: int, word: str | None = None, suggestion: str | None = None):
|
|
355
|
+
c = self.current_component
|
|
356
|
+
if c is None:
|
|
357
|
+
return ExpandError(message, line, self.source, word, suggestion)
|
|
358
|
+
return ExpandError(f"in '{c}': {message}", self.current_include_line, self.source, word, suggestion)
|
|
359
|
+
|
|
360
|
+
def eff_line(self, line: int) -> int:
|
|
361
|
+
return self.current_include_line if self.current_component is not None else line
|
|
362
|
+
|
|
363
|
+
def style_error(self, message, line, word, file, link_line):
|
|
364
|
+
if file is None:
|
|
365
|
+
return self.err(f"style: {message}", line, word)
|
|
366
|
+
return self.err(f"style: in '{file}' line {line}: {message}", link_line, word)
|
|
367
|
+
|
|
368
|
+
def main_fail(self, message, line, word):
|
|
369
|
+
raise self.err(message, line, word)
|
|
370
|
+
|
|
371
|
+
def component_fail(self, name: str, include_line: int):
|
|
372
|
+
def fail(message, _line, word):
|
|
373
|
+
raise ExpandError(f"in '{name}': {message}", include_line, self.source, word)
|
|
374
|
+
return fail
|
|
375
|
+
|
|
376
|
+
def include_error(self, file_name: str, message: str, line: int):
|
|
377
|
+
if not file_name:
|
|
378
|
+
return ExpandError(message, line, self.source)
|
|
379
|
+
return ExpandError(f"in '{file_name}': {message}", line, self.source)
|
|
380
|
+
|
|
381
|
+
# -- .nl file
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _link_href(tag: str, line: int, fail) -> str:
|
|
384
|
+
attrs: dict[str, str] = {}
|
|
385
|
+
inner = tag.removeprefix("<link").removesuffix(">").removesuffix("/")
|
|
386
|
+
for a in ATTR_RE.finditer(inner):
|
|
387
|
+
value = _first(a, 2, 3, 4)
|
|
388
|
+
if value is None:
|
|
389
|
+
continue
|
|
390
|
+
attrs[a.group(1).lower()] = value
|
|
391
|
+
href = attrs.get("href")
|
|
392
|
+
rel = attrs.get("rel")
|
|
393
|
+
if href is None or href.strip() == "" or (rel.lower() if rel is not None else None) != "stylesheet":
|
|
394
|
+
fail('markup: <link> needs rel="stylesheet" and a href', line, None)
|
|
395
|
+
if href.startswith("/") or ".." in href or "://" in href:
|
|
396
|
+
fail(f"style: the file '{href}' is not allowed", line, href)
|
|
397
|
+
if not href.endswith(".css"):
|
|
398
|
+
fail(f"style: the file '{href}' must be a .css file", line, href)
|
|
399
|
+
return href
|
|
400
|
+
|
|
401
|
+
def _scan_file(self, file_lines: list[str], fail) -> FileLayout:
|
|
402
|
+
first = 0
|
|
403
|
+
while first < len(file_lines) and file_lines[first].strip() == "":
|
|
404
|
+
first += 1
|
|
405
|
+
last = len(file_lines) - 1
|
|
406
|
+
while last >= 0 and file_lines[last].strip() == "":
|
|
407
|
+
last -= 1
|
|
408
|
+
if (first >= last or file_lines[first].strip() != "<nativelayout>"
|
|
409
|
+
or file_lines[last].strip() != "</nativelayout>"):
|
|
410
|
+
fail("the file must start with <nativelayout> and end with </nativelayout>",
|
|
411
|
+
first + 1 if first < len(file_lines) else 1, None)
|
|
412
|
+
|
|
413
|
+
style_start = style_end = body_start = body_end = -1
|
|
414
|
+
links: list[tuple[str, int]] = []
|
|
415
|
+
i = first + 1
|
|
416
|
+
in_comment = False
|
|
417
|
+
while i < last:
|
|
418
|
+
t = file_lines[i].strip()
|
|
419
|
+
if in_comment:
|
|
420
|
+
if "-->" in t:
|
|
421
|
+
in_comment = False
|
|
422
|
+
elif t == "":
|
|
423
|
+
pass
|
|
424
|
+
elif t.startswith("<!--"):
|
|
425
|
+
if "-->" not in t:
|
|
426
|
+
in_comment = True
|
|
427
|
+
elif t.startswith("<script"):
|
|
428
|
+
fail("markup: <script> is not allowed", i + 1, None)
|
|
429
|
+
elif t == "<style>":
|
|
430
|
+
if style_start >= 0:
|
|
431
|
+
fail("only one <style> block is allowed", i + 1, None)
|
|
432
|
+
j = i + 1
|
|
433
|
+
while j < last and file_lines[j].strip() != "</style>":
|
|
434
|
+
j += 1
|
|
435
|
+
if j >= last:
|
|
436
|
+
fail("<style> is never closed with </style>", i + 1, None)
|
|
437
|
+
style_start = i + 1
|
|
438
|
+
style_end = j
|
|
439
|
+
i = j
|
|
440
|
+
elif t.startswith("<link"):
|
|
441
|
+
if body_start >= 0:
|
|
442
|
+
fail("a <link> must come before <body>", i + 1, None)
|
|
443
|
+
links.append((self._link_href(t, i + 1, fail), i + 1))
|
|
444
|
+
elif t.startswith("<body"):
|
|
445
|
+
if body_start >= 0:
|
|
446
|
+
fail("the file must have exactly one <body>", i + 1, None)
|
|
447
|
+
j = i + 1
|
|
448
|
+
while j < last and file_lines[j].strip() != "</body>":
|
|
449
|
+
j += 1
|
|
450
|
+
if j >= last:
|
|
451
|
+
fail("<body> is never closed with </body>", i + 1, None)
|
|
452
|
+
body_start = i
|
|
453
|
+
body_end = j
|
|
454
|
+
i = j
|
|
455
|
+
else:
|
|
456
|
+
fail("only <style> and <body> are allowed inside <nativelayout>", i + 1, None)
|
|
457
|
+
i += 1
|
|
458
|
+
if body_start < 0:
|
|
459
|
+
fail("the file must have exactly one <body>", first + 1, None)
|
|
460
|
+
return FileLayout(links, style_start, style_end, body_start, body_end)
|
|
461
|
+
|
|
462
|
+
# -- components: <include>
|
|
463
|
+
def _include_tag_of(self, raw: str, report_line: int, file_name: str) -> IncludeTag | None:
|
|
464
|
+
t = raw.strip()
|
|
465
|
+
if INCLUDE_START.search(t) is None:
|
|
466
|
+
return None
|
|
467
|
+
m = TAG_RE.search(t)
|
|
468
|
+
if m is None or m.start() != 0 or m.end() - 1 != len(t) - 1:
|
|
469
|
+
raise self.include_error(file_name, "markup: <include> must be alone on its line", report_line)
|
|
470
|
+
inner = t[len("<include"):len(t) - 1].removesuffix("/")
|
|
471
|
+
attrs = [(a.group(1), _first(a, 2, 3, 4)) for a in ATTR_RE.finditer(inner)]
|
|
472
|
+
return IncludeTag(attrs)
|
|
473
|
+
|
|
474
|
+
def _component_path(self, frm: str, src: str, line: int) -> str:
|
|
475
|
+
if src.strip() == "" or src.startswith("/") or ".." in src or "://" in src:
|
|
476
|
+
raise self.include_error(frm, f"include: the file '{src}' is not allowed", line)
|
|
477
|
+
if not src.endswith(".nl"):
|
|
478
|
+
raise self.include_error(frm, f"include: the file '{src}' must be a .nl file", line)
|
|
479
|
+
return _join_path(frm, src)
|
|
480
|
+
|
|
481
|
+
def _collect_includes(self, file_name: str, file_lines: list[str], layout: FileLayout,
|
|
482
|
+
chain: list[str], main_line: int) -> None:
|
|
483
|
+
for idx in range(layout.body_start + 1, layout.body_end):
|
|
484
|
+
line = idx + 1 if not file_name else main_line
|
|
485
|
+
tag = self._include_tag_of(file_lines[idx], line, file_name)
|
|
486
|
+
if tag is None:
|
|
487
|
+
continue
|
|
488
|
+
src = tag.get("src")
|
|
489
|
+
if src is None or src.strip() == "":
|
|
490
|
+
raise self.include_error(file_name, "markup: <include> requires a src attribute", line)
|
|
491
|
+
name = self._component_path(file_name, src, line)
|
|
492
|
+
if name == file_name or name in chain:
|
|
493
|
+
raise self.include_error(file_name, f"include: '{name}' includes itself", line)
|
|
494
|
+
if len(chain) >= MAX_INCLUDE_LEVELS:
|
|
495
|
+
raise self.include_error(
|
|
496
|
+
file_name, f"include: too many nested includes (the limit is {MAX_INCLUDE_LEVELS})", line)
|
|
497
|
+
if name in self.components:
|
|
498
|
+
continue
|
|
499
|
+
text = self.resolve(name)
|
|
500
|
+
if text is None:
|
|
501
|
+
raise self.include_error(file_name, f"include: the file '{name}' was not found", line)
|
|
502
|
+
comp_lines = text.split("\n")
|
|
503
|
+
comp_layout = self._scan_file(comp_lines, self.component_fail(name, line))
|
|
504
|
+
self.components[name] = Component(name, text, comp_lines, comp_layout, line)
|
|
505
|
+
self._collect_includes(name, comp_lines, comp_layout, chain + [name], line)
|
|
506
|
+
|
|
507
|
+
def _param_plan(self, name: str, value: str | None) -> ParamPlan:
|
|
508
|
+
if value is None:
|
|
509
|
+
return ParamPlan(name, True, None, None)
|
|
510
|
+
if "{" not in value:
|
|
511
|
+
return ParamPlan(name, value, None, None)
|
|
512
|
+
m = SINGLE_VALUE_RE.fullmatch(value.strip())
|
|
513
|
+
if m is not None:
|
|
514
|
+
return ParamPlan(name, None, m.group(1), None)
|
|
515
|
+
return ParamPlan(name, None, None, self._compile_value(value))
|
|
516
|
+
|
|
517
|
+
def _make_include(self, tag: IncludeTag, file_name: str, line: int) -> IncludeNode:
|
|
518
|
+
src = tag.get("src")
|
|
519
|
+
if src is None:
|
|
520
|
+
raise self.err("markup: <include> requires a src attribute", line)
|
|
521
|
+
component = self.components.get(_join_path(file_name, src))
|
|
522
|
+
if component is None:
|
|
523
|
+
raise self.err(f"include: the file '{src}' was not found", line)
|
|
524
|
+
params: list[ParamPlan] = []
|
|
525
|
+
key = None
|
|
526
|
+
for name, value in tag.attrs:
|
|
527
|
+
n = name.lower()
|
|
528
|
+
if n == "src":
|
|
529
|
+
pass
|
|
530
|
+
elif n == "key":
|
|
531
|
+
key = self._compile_value(value) if value is not None else None
|
|
532
|
+
else:
|
|
533
|
+
params.append(self._param_plan(name, value))
|
|
534
|
+
return IncludeNode(line, component, params, key)
|
|
535
|
+
|
|
536
|
+
# -- input
|
|
537
|
+
def expand(self) -> ExpandedScreen:
|
|
538
|
+
layout = self._scan_file(self.lines, self.main_fail)
|
|
539
|
+
self._collect_includes("", self.lines, layout, [], 0)
|
|
540
|
+
style_start, style_end = layout.style_start, layout.style_end
|
|
541
|
+
|
|
542
|
+
component_loaded: set[str] = set()
|
|
543
|
+
component_sheets: list[tuple[str, int, str]] = []
|
|
544
|
+
for comp in self.components.values():
|
|
545
|
+
for href, _ in comp.layout.links:
|
|
546
|
+
path = _join_path(comp.name, href)
|
|
547
|
+
if path in component_loaded:
|
|
548
|
+
continue
|
|
549
|
+
component_loaded.add(path)
|
|
550
|
+
css = self.resolve(path)
|
|
551
|
+
if css is None:
|
|
552
|
+
raise ExpandError(f"style: the file '{path}' was not found", comp.include_line, self.source, path)
|
|
553
|
+
if "@import" in css:
|
|
554
|
+
raise ExpandError("style: a style file cannot import another file",
|
|
555
|
+
comp.include_line, self.source, path)
|
|
556
|
+
component_sheets.append((path, comp.include_line, css))
|
|
557
|
+
|
|
558
|
+
loaded: set[str] = set()
|
|
559
|
+
sheets: list[tuple[str, int, str]] = []
|
|
560
|
+
for href, link_line in layout.links:
|
|
561
|
+
if href in loaded:
|
|
562
|
+
continue
|
|
563
|
+
loaded.add(href)
|
|
564
|
+
css = self.resolve(href)
|
|
565
|
+
if css is None:
|
|
566
|
+
raise self.err(f"style: the file '{href}' was not found", link_line, href)
|
|
567
|
+
if "@import" in css:
|
|
568
|
+
raise self.err("style: a style file cannot import another file", link_line, href)
|
|
569
|
+
sheets.append((href, link_line, css))
|
|
570
|
+
|
|
571
|
+
key = ["d" if self.dark else "l", "\x01", self.source]
|
|
572
|
+
for comp in self.components.values():
|
|
573
|
+
key += ["\x02", comp.name, "\x03", comp.source]
|
|
574
|
+
for s in component_sheets:
|
|
575
|
+
key += ["\x00", s[2]]
|
|
576
|
+
key.append("\x04")
|
|
577
|
+
for s in sheets:
|
|
578
|
+
key += ["\x00", s[2]]
|
|
579
|
+
cache_key = "".join(key)
|
|
580
|
+
|
|
581
|
+
hit = _cached(cache_key)
|
|
582
|
+
if hit is not None:
|
|
583
|
+
prepared = hit
|
|
584
|
+
self.rules = hit.rules
|
|
585
|
+
else:
|
|
586
|
+
for s in component_sheets:
|
|
587
|
+
self._parse_style(s[2], 1, s[0], s[1])
|
|
588
|
+
for comp in self.components.values():
|
|
589
|
+
l = comp.layout
|
|
590
|
+
if l.style_start >= 0:
|
|
591
|
+
self._parse_style("\n".join(comp.lines[l.style_start:l.style_end]),
|
|
592
|
+
l.style_start + 1, comp.name, comp.include_line)
|
|
593
|
+
for s in sheets:
|
|
594
|
+
self._parse_style(s[2], 1, s[0], s[1])
|
|
595
|
+
if style_start >= 0:
|
|
596
|
+
self._parse_style("\n".join(self.lines[style_start:style_end]), style_start + 1)
|
|
597
|
+
self._resolve_variables()
|
|
598
|
+
for comp in self.components.values():
|
|
599
|
+
self.current_component = comp.name
|
|
600
|
+
self.current_include_line = comp.include_line
|
|
601
|
+
comp.nodes = self._build_nodes(comp.lines, comp.layout.body_start,
|
|
602
|
+
comp.layout.body_end, comp.name)
|
|
603
|
+
self.current_component = None
|
|
604
|
+
prepared = Prepared(self.rules, self._build_nodes(self.lines, layout.body_start,
|
|
605
|
+
layout.body_end, ""))
|
|
606
|
+
_store(cache_key, prepared)
|
|
607
|
+
self._emit(prepared.nodes, Scope(None, self.data))
|
|
608
|
+
return ExpandedScreen("\n".join(self.out), self.out_lines, list(self.warnings), dict(self.scope_log))
|
|
609
|
+
|
|
610
|
+
# -- styles
|
|
611
|
+
def _flatten_media(self, text: str, first_line: int, file, link_line) -> str:
|
|
612
|
+
t = text
|
|
613
|
+
while True:
|
|
614
|
+
at = t.find("@media")
|
|
615
|
+
if at < 0:
|
|
616
|
+
return t
|
|
617
|
+
line = first_line + t.count("\n", 0, at)
|
|
618
|
+
op = t.find("{", at)
|
|
619
|
+
if op < 0:
|
|
620
|
+
raise self.style_error("'@media' without '{'", line, "@media", file, link_line)
|
|
621
|
+
depth = 0
|
|
622
|
+
close = -1
|
|
623
|
+
i = op
|
|
624
|
+
while i < len(t):
|
|
625
|
+
c = t[i]
|
|
626
|
+
if c == "{":
|
|
627
|
+
depth += 1
|
|
628
|
+
elif c == "}":
|
|
629
|
+
depth -= 1
|
|
630
|
+
if depth == 0:
|
|
631
|
+
close = i
|
|
632
|
+
break
|
|
633
|
+
i += 1
|
|
634
|
+
if close < 0:
|
|
635
|
+
raise self.style_error("the '@media' block is never closed", line, "@media", file, link_line)
|
|
636
|
+
condition = WS_RE.sub(" ", t[at + 6:op].strip().lower())
|
|
637
|
+
norm = condition.replace(": ", ":")
|
|
638
|
+
if norm == "(prefers-color-scheme:dark)":
|
|
639
|
+
include = self.dark
|
|
640
|
+
elif norm == "(prefers-color-scheme:light)":
|
|
641
|
+
include = not self.dark
|
|
642
|
+
else:
|
|
643
|
+
raise self.style_error(
|
|
644
|
+
f"the '@media {condition}' is not supported "
|
|
645
|
+
"(use '@media (prefers-color-scheme: dark)' or '... light')",
|
|
646
|
+
line, "@media", file, link_line)
|
|
647
|
+
replacement = t[op + 1:close] if include else "".join(c for c in t[at:close + 1] if c == "\n")
|
|
648
|
+
t = t[:at] + replacement + t[close + 1:]
|
|
649
|
+
|
|
650
|
+
def _parse_style(self, text: str, first_line: int, file: str | None = None, link_line: int = 0) -> None:
|
|
651
|
+
commentless = COMMENT_RE.sub(lambda m: "".join(c for c in m.group(0) if c == "\n"), text)
|
|
652
|
+
clean = self._flatten_media(commentless, first_line, file, link_line)
|
|
653
|
+
for m in RULE_RE.finditer(clean):
|
|
654
|
+
selector_text = m.group(1)
|
|
655
|
+
offset = m.start(1) + (len(selector_text) - len(selector_text.lstrip()))
|
|
656
|
+
line = first_line + clean.count("\n", 0, offset)
|
|
657
|
+
declarations: list[tuple[str, str]] = []
|
|
658
|
+
for part in m.group(2).split(";"):
|
|
659
|
+
idx = part.find(":")
|
|
660
|
+
if idx < 0:
|
|
661
|
+
continue
|
|
662
|
+
name = part[:idx].strip().lower()
|
|
663
|
+
value = part[idx + 1:].replace("!important", "").strip()
|
|
664
|
+
if not name or not value:
|
|
665
|
+
continue
|
|
666
|
+
declarations.append((name, value))
|
|
667
|
+
for raw in selector_text.split(","):
|
|
668
|
+
full = raw.strip()
|
|
669
|
+
selector = full
|
|
670
|
+
state = STATE_NONE
|
|
671
|
+
colon = full.find(":")
|
|
672
|
+
if colon > 0:
|
|
673
|
+
s = full[colon + 1:].lower()
|
|
674
|
+
if s in ("pressed", "active"):
|
|
675
|
+
state = STATE_PRESSED
|
|
676
|
+
elif s == "disabled":
|
|
677
|
+
state = STATE_DISABLED
|
|
678
|
+
elif s == "focus":
|
|
679
|
+
state = STATE_FOCUS
|
|
680
|
+
else:
|
|
681
|
+
raise self.style_error(
|
|
682
|
+
f"the selector '{full}' is not supported (states: :pressed, :disabled, :focus)",
|
|
683
|
+
line, full, file, link_line)
|
|
684
|
+
selector = full[:colon]
|
|
685
|
+
if selector == ":root" and state == STATE_NONE:
|
|
686
|
+
for d in declarations:
|
|
687
|
+
if d[0].startswith("--"):
|
|
688
|
+
self.variables[d[0]] = d[1]
|
|
689
|
+
elif TAG_SELECTOR.fullmatch(selector):
|
|
690
|
+
self.rules.append(Rule(KIND_TAG, selector.lower(), declarations, self.rule_order, line, file, link_line, state))
|
|
691
|
+
self.rule_order += 1
|
|
692
|
+
elif CLASS_SELECTOR.fullmatch(selector):
|
|
693
|
+
self.rules.append(Rule(KIND_CLASS, selector[1:], declarations, self.rule_order, line, file, link_line, state))
|
|
694
|
+
self.rule_order += 1
|
|
695
|
+
elif ID_SELECTOR.fullmatch(selector):
|
|
696
|
+
self.rules.append(Rule(KIND_ID, selector[1:], declarations, self.rule_order, line, file, link_line, state))
|
|
697
|
+
self.rule_order += 1
|
|
698
|
+
else:
|
|
699
|
+
raise self.style_error(f"the selector '{full}' is not supported", line, full, file, link_line)
|
|
700
|
+
|
|
701
|
+
def _substitute_vars(self, value: str, rule: Rule) -> str:
|
|
702
|
+
def rep(m):
|
|
703
|
+
name = m.group(1)
|
|
704
|
+
if name not in self.variables:
|
|
705
|
+
raise self.style_error(f"the variable '{name}' is not defined in ':root'",
|
|
706
|
+
rule.line, name, rule.file, rule.link_line)
|
|
707
|
+
return self.variables[name]
|
|
708
|
+
return VAR_RE.sub(rep, value)
|
|
709
|
+
|
|
710
|
+
def _resolve_variables(self) -> None:
|
|
711
|
+
for rule in self.rules:
|
|
712
|
+
rule.resolved = [(n, self._substitute_vars(v, rule))
|
|
713
|
+
for n, v in rule.declarations if not n.startswith("--")]
|
|
714
|
+
|
|
715
|
+
# -- control lines
|
|
716
|
+
def _parse_control(self, t: str, line: int) -> Control | None:
|
|
717
|
+
if t == "end":
|
|
718
|
+
return Control("end", "")
|
|
719
|
+
if t == "else:":
|
|
720
|
+
return Control("else", "")
|
|
721
|
+
if ELSE_SPACE_RE.fullmatch(t):
|
|
722
|
+
raise self.err("write 'else:' without a space before ':'", line)
|
|
723
|
+
km = KEYWORD_RE.match(t)
|
|
724
|
+
if km is None:
|
|
725
|
+
return None
|
|
726
|
+
keyword = km.group(1)
|
|
727
|
+
if not t.endswith(":"):
|
|
728
|
+
return None
|
|
729
|
+
body = t[:-1]
|
|
730
|
+
if body.endswith(" ") or body.endswith("\t"):
|
|
731
|
+
raise self.err(f"write '{body.rstrip()}:' without a space before ':'", line)
|
|
732
|
+
if keyword == "for":
|
|
733
|
+
m = FOR_RE.fullmatch(t)
|
|
734
|
+
if m is None:
|
|
735
|
+
raise self.err(f"invalid 'for': '{t}'", line, None, "write 'for item in list:'")
|
|
736
|
+
return Control("for", m.group(2), m.group(1))
|
|
737
|
+
condition = body[len(keyword):].strip()
|
|
738
|
+
if not condition:
|
|
739
|
+
raise self.err("invalid condition: ''", line)
|
|
740
|
+
return Control(keyword, condition)
|
|
741
|
+
|
|
742
|
+
def _build_nodes(self, file_lines: list[str], frm: int, to: int, file_name: str) -> list[Node]:
|
|
743
|
+
root: list[Node] = []
|
|
744
|
+
stack: list[Frame] = [Frame(0, root, None)]
|
|
745
|
+
for idx in range(frm, to + 1):
|
|
746
|
+
line = idx + 1
|
|
747
|
+
edge = idx == frm or idx == to
|
|
748
|
+
control = None if edge else self._parse_control(file_lines[idx].strip(), line)
|
|
749
|
+
top = stack[-1]
|
|
750
|
+
if control is None:
|
|
751
|
+
include = None if edge else self._include_tag_of(
|
|
752
|
+
file_lines[idx], line if not file_name else self.current_include_line, file_name)
|
|
753
|
+
if include is not None:
|
|
754
|
+
top.nodes.append(self._make_include(include, file_name, line))
|
|
755
|
+
else:
|
|
756
|
+
top.nodes.append(TextNode(file_lines[idx], line))
|
|
757
|
+
continue
|
|
758
|
+
if control.type == "if":
|
|
759
|
+
node = IfNode(line)
|
|
760
|
+
branch = Branch(control.argument, line)
|
|
761
|
+
node.branches.append(branch)
|
|
762
|
+
top.nodes.append(node)
|
|
763
|
+
stack.append(Frame(line, branch.nodes, node))
|
|
764
|
+
elif control.type in ("elif", "else"):
|
|
765
|
+
if_node = top.if_node
|
|
766
|
+
if if_node is None:
|
|
767
|
+
raise self.err(f"'{control.type}' has no matching 'if'", line)
|
|
768
|
+
if if_node.branches[-1].condition is None:
|
|
769
|
+
raise self.err(f"'{control.type}' has no matching 'if'", line)
|
|
770
|
+
branch = Branch(None if control.type == "else" else control.argument, line)
|
|
771
|
+
if_node.branches.append(branch)
|
|
772
|
+
top.nodes = branch.nodes
|
|
773
|
+
elif control.type == "for":
|
|
774
|
+
node = ForNode(line, control.variable, control.argument)
|
|
775
|
+
top.nodes.append(node)
|
|
776
|
+
stack.append(Frame(line, node.nodes, None))
|
|
777
|
+
else:
|
|
778
|
+
if len(stack) == 1:
|
|
779
|
+
raise self.err("'end' has no matching 'if' or 'for'", line)
|
|
780
|
+
stack.pop()
|
|
781
|
+
if len(stack) > 1:
|
|
782
|
+
open_ = stack[-1]
|
|
783
|
+
word = "if" if open_.if_node is not None else "for"
|
|
784
|
+
raise self.err(f"the '{word}' opened on line {open_.open_line} is never closed with 'end'",
|
|
785
|
+
open_.open_line)
|
|
786
|
+
return root
|
|
787
|
+
|
|
788
|
+
# -- emission
|
|
789
|
+
def _emit(self, nodes: list[Node], scope: Scope) -> None:
|
|
790
|
+
for node in nodes:
|
|
791
|
+
if isinstance(node, TextNode):
|
|
792
|
+
if node.parts is None:
|
|
793
|
+
node.parts = self._compile_line(node.text)
|
|
794
|
+
line = self.eff_line(node.line)
|
|
795
|
+
self.out.append(self._render_parts(node.parts, line, scope))
|
|
796
|
+
self.out_lines.append(line)
|
|
797
|
+
elif isinstance(node, IfNode):
|
|
798
|
+
for branch in node.branches:
|
|
799
|
+
cond = branch.condition
|
|
800
|
+
if cond is None or self._evaluate(branch, cond, scope):
|
|
801
|
+
self._emit(branch.nodes, scope)
|
|
802
|
+
break
|
|
803
|
+
elif isinstance(node, ForNode):
|
|
804
|
+
self._emit_for(node, scope)
|
|
805
|
+
elif isinstance(node, IncludeNode):
|
|
806
|
+
self._emit_include(node, scope)
|
|
807
|
+
|
|
808
|
+
def _emit_for(self, node: ForNode, scope: Scope) -> None:
|
|
809
|
+
ln = self.eff_line(node.line)
|
|
810
|
+
lst = self._lookup(node.list_name, ln, scope)
|
|
811
|
+
if not isinstance(lst, list):
|
|
812
|
+
raise self.err(f"'{node.list_name}' is not a list", ln, node.list_name)
|
|
813
|
+
if scope.has(node.variable):
|
|
814
|
+
raise self.err(f"the name '{node.variable}' already exists", ln, node.variable)
|
|
815
|
+
position_warned = False
|
|
816
|
+
for index, item in enumerate(lst):
|
|
817
|
+
# ids and taps inside the item carry the path of the repetition; it is written with a marker
|
|
818
|
+
# because the key of the item is only known after the item has been emitted.
|
|
819
|
+
marker = f"\x01{self.scope_counter}\x02"
|
|
820
|
+
self.scope_counter += 1
|
|
821
|
+
child_path = marker if not scope.path else scope.path + "/" + marker
|
|
822
|
+
first = len(self.out)
|
|
823
|
+
log_first = len(self.scope_log)
|
|
824
|
+
hits = self.marker_hits
|
|
825
|
+
self._emit(node.nodes, Scope(scope, {node.variable: item}, child_path))
|
|
826
|
+
item_key = Scopes.key_of_item(item)
|
|
827
|
+
if self.marker_hits != hits:
|
|
828
|
+
fl = self._first_level_key(first)
|
|
829
|
+
segment = node.variable + ":" + Scopes.token(fl if fl is not None else item_key, index)
|
|
830
|
+
for k in range(first, len(self.out)):
|
|
831
|
+
if "\x01" in self.out[k]:
|
|
832
|
+
self.out[k] = self.out[k].replace(marker, segment)
|
|
833
|
+
for k in range(log_first, len(self.scope_log)):
|
|
834
|
+
p, it = self.scope_log[k]
|
|
835
|
+
if "\x01" in p:
|
|
836
|
+
self.scope_log[k] = (p.replace(marker, segment), it)
|
|
837
|
+
self.scope_log.append((segment if not scope.path else scope.path + "/" + segment, item))
|
|
838
|
+
keyed = self._apply_key(first, item_key)
|
|
839
|
+
if not keyed and not position_warned and self._holds_fields_or_ids(first):
|
|
840
|
+
position_warned = True
|
|
841
|
+
w = LayoutWarning("the items of this list have no id: using the position", ln)
|
|
842
|
+
if not any(x.message == w.message and x.line == w.line for x in self.warnings):
|
|
843
|
+
self.warnings.append(w)
|
|
844
|
+
|
|
845
|
+
def _emit_include(self, node: IncludeNode, scope: Scope) -> None:
|
|
846
|
+
ln = self.eff_line(node.line)
|
|
847
|
+
given: dict[str, object] = {}
|
|
848
|
+
for p in node.params:
|
|
849
|
+
given[p.name] = self._param_value(p, ln, scope)
|
|
850
|
+
key_text = None
|
|
851
|
+
if node.key is not None:
|
|
852
|
+
kt = self._raw_text(node.key, ln, scope).strip()
|
|
853
|
+
key_text = kt or None
|
|
854
|
+
first = len(self.out)
|
|
855
|
+
saved_component = self.current_component
|
|
856
|
+
saved_line = self.current_include_line
|
|
857
|
+
if saved_component is None:
|
|
858
|
+
self.current_include_line = node.line
|
|
859
|
+
self.current_component = node.component.name
|
|
860
|
+
try:
|
|
861
|
+
self._emit(node.component.nodes, Scope(None, given, scope.path))
|
|
862
|
+
finally:
|
|
863
|
+
self.current_component = saved_component
|
|
864
|
+
self.current_include_line = saved_line
|
|
865
|
+
if key_text is not None:
|
|
866
|
+
self._apply_key(first, key_text)
|
|
867
|
+
|
|
868
|
+
def _param_value(self, p: ParamPlan, line: int, scope: Scope):
|
|
869
|
+
if p.single is not None:
|
|
870
|
+
return self._lookup_path(p.single, p.single.split("."), line, scope)
|
|
871
|
+
if p.parts is not None:
|
|
872
|
+
return self._raw_text(p.parts, line, scope)
|
|
873
|
+
return p.constant
|
|
874
|
+
|
|
875
|
+
def _raw_text(self, parts: list[Part], line: int, scope: Scope) -> str:
|
|
876
|
+
sb = []
|
|
877
|
+
for p in parts:
|
|
878
|
+
if isinstance(p, Lit):
|
|
879
|
+
sb.append(p.s)
|
|
880
|
+
elif isinstance(p, Val):
|
|
881
|
+
sb.append(self._raw_value(p, line, scope))
|
|
882
|
+
return "".join(sb)
|
|
883
|
+
|
|
884
|
+
def _value_text(self, v: Val, line: int, scope: Scope, escape: bool) -> str:
|
|
885
|
+
if not v.ok:
|
|
886
|
+
raise self.err(f"arithmetic is not supported in '{{{v.inner}}}'; give the program's result as a name", line)
|
|
887
|
+
name = v.name
|
|
888
|
+
x = self._lookup_path(name, v.path, line, scope)
|
|
889
|
+
if x is None:
|
|
890
|
+
return ""
|
|
891
|
+
if isinstance(x, str):
|
|
892
|
+
return _escape_html(x) if escape else x
|
|
893
|
+
if isinstance(x, bool):
|
|
894
|
+
return "true" if x else "false"
|
|
895
|
+
if _is_num(x):
|
|
896
|
+
return _format_number(x)
|
|
897
|
+
if isinstance(x, list):
|
|
898
|
+
raise self.err(f"'{{{name}}}' is a list; use 'for {_singular(name)} in {name}:'", line, name)
|
|
899
|
+
if isinstance(x, dict):
|
|
900
|
+
raise self.err(f"'{{{name}}}' is a map; use a name inside it, like '{{{name}.field}}'", line, name)
|
|
901
|
+
return _escape_html(str(x)) if escape else str(x)
|
|
902
|
+
|
|
903
|
+
def _raw_value(self, v: Val, line: int, scope: Scope) -> str:
|
|
904
|
+
return self._value_text(v, line, scope, False)
|
|
905
|
+
|
|
906
|
+
def _val_text(self, v: Val, line: int, scope: Scope) -> str:
|
|
907
|
+
return self._value_text(v, line, scope, True)
|
|
908
|
+
|
|
909
|
+
# -- keys of the repetitions
|
|
910
|
+
def _first_level_key(self, first: int) -> str | None:
|
|
911
|
+
depth = 0
|
|
912
|
+
for k in range(first, len(self.out)):
|
|
913
|
+
for m in TAG_RE.finditer(self.out[k]):
|
|
914
|
+
tag = m.group(0)
|
|
915
|
+
if tag.startswith("<!"):
|
|
916
|
+
continue
|
|
917
|
+
if tag.startswith("</"):
|
|
918
|
+
depth -= 1
|
|
919
|
+
continue
|
|
920
|
+
nm = TAG_NAME_RE.search(tag)
|
|
921
|
+
if nm is None:
|
|
922
|
+
continue
|
|
923
|
+
name = nm.group(1).lower()
|
|
924
|
+
# a key that an inner `for` wrote by itself is not the key of the author
|
|
925
|
+
if depth == 0 and k not in self.auto_keyed:
|
|
926
|
+
km = KEY_ATTR.search(tag)
|
|
927
|
+
if km is not None:
|
|
928
|
+
return km.group(1)
|
|
929
|
+
if name not in VOID_NAMES and not tag.endswith("/>"):
|
|
930
|
+
depth += 1
|
|
931
|
+
return None
|
|
932
|
+
|
|
933
|
+
def _apply_key(self, first: int, key: str | None) -> bool:
|
|
934
|
+
if key is None:
|
|
935
|
+
return False
|
|
936
|
+
escaped = _escape_html(key)
|
|
937
|
+
depth = 0
|
|
938
|
+
for k in range(first, len(self.out)):
|
|
939
|
+
line = self.out[k]
|
|
940
|
+
rebuilt: list[str] | None = None
|
|
941
|
+
copied = 0
|
|
942
|
+
for m in TAG_RE.finditer(line):
|
|
943
|
+
tag = m.group(0)
|
|
944
|
+
if tag.startswith("<!"):
|
|
945
|
+
continue
|
|
946
|
+
if tag.startswith("</"):
|
|
947
|
+
depth -= 1
|
|
948
|
+
continue
|
|
949
|
+
nm = TAG_NAME_RE.search(tag)
|
|
950
|
+
if nm is None:
|
|
951
|
+
continue
|
|
952
|
+
name = nm.group(1).lower()
|
|
953
|
+
if depth == 0 and HAS_KEY.search(tag) is None:
|
|
954
|
+
if rebuilt is None:
|
|
955
|
+
rebuilt = []
|
|
956
|
+
rebuilt.append(line[copied:m.start()])
|
|
957
|
+
n = len(name) + 1
|
|
958
|
+
rebuilt.append(tag[:n] + ' key="' + escaped + '"' + tag[n:])
|
|
959
|
+
copied = m.end()
|
|
960
|
+
if name not in VOID_NAMES and not tag.endswith("/>"):
|
|
961
|
+
depth += 1
|
|
962
|
+
if rebuilt is not None:
|
|
963
|
+
rebuilt.append(line[copied:])
|
|
964
|
+
self.out[k] = "".join(rebuilt)
|
|
965
|
+
self.auto_keyed.add(k)
|
|
966
|
+
return True
|
|
967
|
+
|
|
968
|
+
def _holds_fields_or_ids(self, first: int) -> bool:
|
|
969
|
+
return any(FIELD_OR_ID.search(self.out[k]) for k in range(first, len(self.out)))
|
|
970
|
+
|
|
971
|
+
# -- names
|
|
972
|
+
def _lookup(self, name: str, line: int, scope: Scope):
|
|
973
|
+
return self._lookup_path(name, name.split("."), line, scope)
|
|
974
|
+
|
|
975
|
+
def _missing(self, name: str, line: int, word: str):
|
|
976
|
+
c = self.current_component
|
|
977
|
+
if c is None:
|
|
978
|
+
return self.err(f"the name '{name}' was not given by the program", line, word)
|
|
979
|
+
return ExpandError(f"include: the name '{name}' was not given to '{c}'",
|
|
980
|
+
self.current_include_line, self.source, word)
|
|
981
|
+
|
|
982
|
+
def _lookup_path(self, name: str, parts: list[str], line: int, scope: Scope):
|
|
983
|
+
value = scope.find(parts[0])
|
|
984
|
+
if value is _MISSING:
|
|
985
|
+
raise self._missing(name, line, parts[0])
|
|
986
|
+
for part in parts[1:]:
|
|
987
|
+
cur = value
|
|
988
|
+
if isinstance(cur, dict) and part in cur:
|
|
989
|
+
value = cur[part]
|
|
990
|
+
elif part == "size" and isinstance(cur, (dict, list, str)):
|
|
991
|
+
value = len(cur)
|
|
992
|
+
else:
|
|
993
|
+
raise self._missing(name, line, parts[0])
|
|
994
|
+
return value
|
|
995
|
+
|
|
996
|
+
# -- conditions
|
|
997
|
+
def _tokenize(self, text: str, line: int) -> list[str]:
|
|
998
|
+
tokens: list[str] = []
|
|
999
|
+
pos = 0
|
|
1000
|
+
while pos < len(text):
|
|
1001
|
+
if text[pos].isspace():
|
|
1002
|
+
pos += 1
|
|
1003
|
+
continue
|
|
1004
|
+
m = TOKEN_RE.match(text, pos)
|
|
1005
|
+
if m is None:
|
|
1006
|
+
raise self.err(f"invalid condition: '{text}'", line)
|
|
1007
|
+
tokens.append(m.group(0))
|
|
1008
|
+
pos = m.end()
|
|
1009
|
+
return tokens
|
|
1010
|
+
|
|
1011
|
+
def _evaluate(self, branch: Branch, condition: str, scope: Scope) -> bool:
|
|
1012
|
+
if branch.tokens is None:
|
|
1013
|
+
branch.tokens = self._tokenize(condition, branch.line)
|
|
1014
|
+
return _ConditionParser(self, branch.tokens, condition, branch.line, scope).run()
|
|
1015
|
+
|
|
1016
|
+
# -- compiling lines
|
|
1017
|
+
def _compile_text(self, text: str, lst: list[Part]) -> None:
|
|
1018
|
+
pos = 0
|
|
1019
|
+
for m in BRACE_RE.finditer(text):
|
|
1020
|
+
if m.start() > pos:
|
|
1021
|
+
lst.append(Lit(text[pos:m.start()]))
|
|
1022
|
+
v = m.group(0)
|
|
1023
|
+
if v == "\\{":
|
|
1024
|
+
lst.append(Lit("{"))
|
|
1025
|
+
elif v == "\\}":
|
|
1026
|
+
lst.append(Lit("}"))
|
|
1027
|
+
else:
|
|
1028
|
+
lst.append(Val(m.group(1)))
|
|
1029
|
+
pos = m.end()
|
|
1030
|
+
if pos < len(text):
|
|
1031
|
+
lst.append(Lit(text[pos:]))
|
|
1032
|
+
|
|
1033
|
+
def _compile_value(self, text: str) -> list[Part]:
|
|
1034
|
+
lst: list[Part] = []
|
|
1035
|
+
self._compile_text(text, lst)
|
|
1036
|
+
return _merge_lits(lst)
|
|
1037
|
+
|
|
1038
|
+
def _compile_line(self, text: str) -> list[Part]:
|
|
1039
|
+
lst: list[Part] = []
|
|
1040
|
+
pos = 0
|
|
1041
|
+
for m in TAG_RE.finditer(text):
|
|
1042
|
+
self._compile_text(text[pos:m.start()], lst)
|
|
1043
|
+
lst.append(self._compile_tag(m.group(0)))
|
|
1044
|
+
pos = m.end()
|
|
1045
|
+
self._compile_text(text[pos:], lst)
|
|
1046
|
+
return _merge_lits(lst)
|
|
1047
|
+
|
|
1048
|
+
def _render_parts(self, parts: list[Part], line: int, scope: Scope) -> str:
|
|
1049
|
+
if len(parts) == 1 and isinstance(parts[0], Lit):
|
|
1050
|
+
return parts[0].s
|
|
1051
|
+
sb = []
|
|
1052
|
+
for p in parts:
|
|
1053
|
+
if isinstance(p, Lit):
|
|
1054
|
+
sb.append(p.s)
|
|
1055
|
+
elif isinstance(p, Val):
|
|
1056
|
+
sb.append(self._val_text(p, line, scope))
|
|
1057
|
+
elif isinstance(p, DynTag):
|
|
1058
|
+
sb.append(p.pre if p.pre is not None and not scope.path else self._render_dyn(p, line, scope))
|
|
1059
|
+
return "".join(sb)
|
|
1060
|
+
|
|
1061
|
+
def _style_for(self, tag: str, classes: list[str], id_: str | None, state: int = STATE_NONE) -> str:
|
|
1062
|
+
def match(r: Rule) -> bool:
|
|
1063
|
+
if r.state != state:
|
|
1064
|
+
return False
|
|
1065
|
+
if r.kind == KIND_TAG:
|
|
1066
|
+
return r.name == tag
|
|
1067
|
+
if r.kind == KIND_CLASS:
|
|
1068
|
+
return r.name in classes
|
|
1069
|
+
return id_ is not None and r.name == id_
|
|
1070
|
+
matched = sorted((r for r in self.rules if match(r)), key=lambda r: (r.kind, r.order))
|
|
1071
|
+
return "; ".join(f"{n}: {v}" for r in matched for n, v in r.resolved)
|
|
1072
|
+
|
|
1073
|
+
def _compile_tag(self, tag: str) -> Part:
|
|
1074
|
+
if tag.startswith("<!"):
|
|
1075
|
+
return Lit(tag)
|
|
1076
|
+
if tag.startswith("</"):
|
|
1077
|
+
closing = tag[2:len(tag) - 1].strip().lower()
|
|
1078
|
+
return Lit("</div>" if closing == "body" else tag)
|
|
1079
|
+
nm = TAG_NAME_RE.search(tag)
|
|
1080
|
+
if nm is None:
|
|
1081
|
+
return Lit(tag)
|
|
1082
|
+
name = nm.group(1)
|
|
1083
|
+
lower = name.lower()
|
|
1084
|
+
self_close = tag.endswith("/>")
|
|
1085
|
+
inner = tag[nm.end():len(tag) - (2 if self_close else 1)]
|
|
1086
|
+
|
|
1087
|
+
attrs: list[AttrPlan] = []
|
|
1088
|
+
classes: list[str] = []
|
|
1089
|
+
id_: str | None = None
|
|
1090
|
+
any_brace = False
|
|
1091
|
+
for a in ATTR_RE.finditer(inner):
|
|
1092
|
+
key = a.group(1).lower()
|
|
1093
|
+
value = _first(a, 2, 3, 4)
|
|
1094
|
+
brace = value is not None and "{" in value
|
|
1095
|
+
single = None
|
|
1096
|
+
text_parts = None
|
|
1097
|
+
if brace:
|
|
1098
|
+
any_brace = True
|
|
1099
|
+
sm = SINGLE_VALUE_RE.fullmatch(value.strip())
|
|
1100
|
+
single = sm.group(1) if sm is not None else None
|
|
1101
|
+
text_parts = self._compile_value(value)
|
|
1102
|
+
else:
|
|
1103
|
+
if key == "class":
|
|
1104
|
+
classes = [c for c in (value or "").split(" ") if c]
|
|
1105
|
+
if key == "id":
|
|
1106
|
+
id_ = value
|
|
1107
|
+
attrs.append(AttrPlan(a.group(1), key, value, brace, single, text_parts))
|
|
1108
|
+
plan = DynTag(name, lower, self_close, self._style_for(lower, classes, id_), attrs,
|
|
1109
|
+
(self._style_for(lower, classes, id_, STATE_PRESSED),
|
|
1110
|
+
self._style_for(lower, classes, id_, STATE_DISABLED),
|
|
1111
|
+
self._style_for(lower, classes, id_, STATE_FOCUS)))
|
|
1112
|
+
if any_brace:
|
|
1113
|
+
return plan
|
|
1114
|
+
pre = self._render_dyn(plan, 0, Scope(None, {}))
|
|
1115
|
+
# an id or a class may need the path of a repetition, only known when the line is emitted
|
|
1116
|
+
if any(a.key in ("id", "class") for a in attrs):
|
|
1117
|
+
plan.pre = pre
|
|
1118
|
+
return plan
|
|
1119
|
+
return Lit(pre)
|
|
1120
|
+
|
|
1121
|
+
def _render_dyn(self, t: DynTag, line: int, scope: Scope) -> str:
|
|
1122
|
+
result: list[Attr] = []
|
|
1123
|
+
inline = ""
|
|
1124
|
+
scoped = False
|
|
1125
|
+
for a in t.attrs:
|
|
1126
|
+
value = a.value
|
|
1127
|
+
if a.brace:
|
|
1128
|
+
if a.key in ("id", "class"):
|
|
1129
|
+
raise self.err(f"markup: '{a.key}' cannot be generated by value", line, a.key)
|
|
1130
|
+
if a.key in ("checked", "disabled", "readonly") and a.single is not None:
|
|
1131
|
+
flag = self._lookup(a.single, line, scope)
|
|
1132
|
+
if flag is True:
|
|
1133
|
+
result.append(Attr(a.orig, "true"))
|
|
1134
|
+
continue
|
|
1135
|
+
value = self._render_parts(a.text_parts, line, scope)
|
|
1136
|
+
if a.key in ("id", "class") and scope.path:
|
|
1137
|
+
scoped = True
|
|
1138
|
+
if a.key == "id" and value is not None:
|
|
1139
|
+
value = value + Scopes.SEP + scope.path
|
|
1140
|
+
if a.key == "style":
|
|
1141
|
+
inline = value or ""
|
|
1142
|
+
else:
|
|
1143
|
+
result.append(Attr(a.orig, value))
|
|
1144
|
+
if scoped:
|
|
1145
|
+
result.append(Attr("scope", scope.path))
|
|
1146
|
+
self.marker_hits += 1
|
|
1147
|
+
|
|
1148
|
+
parts: list[str] = []
|
|
1149
|
+
if t.rule_style:
|
|
1150
|
+
parts.append(t.rule_style)
|
|
1151
|
+
inline_style = inline.strip().rstrip(";").strip()
|
|
1152
|
+
if inline_style:
|
|
1153
|
+
parts.append(inline_style)
|
|
1154
|
+
if parts:
|
|
1155
|
+
result.append(Attr("style", "; ".join(parts)))
|
|
1156
|
+
for k in range(3):
|
|
1157
|
+
if t.state_styles[k]:
|
|
1158
|
+
result.append(Attr(STATE_ATTRIBUTES[k], t.state_styles[k]))
|
|
1159
|
+
|
|
1160
|
+
sb = ["<", "div" if t.lower == "body" else t.name]
|
|
1161
|
+
for a in result:
|
|
1162
|
+
sb.append(" " + a.key)
|
|
1163
|
+
if a.value is not None:
|
|
1164
|
+
sb.append('="' + a.value.replace('"', """) + '"')
|
|
1165
|
+
sb.append(" />" if t.self_close else ">")
|
|
1166
|
+
return "".join(sb)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
class _ConditionParser:
|
|
1170
|
+
def __init__(self, owner: _ScreenExpander, tokens: list[str], text: str, line: int, scope: Scope):
|
|
1171
|
+
self.o = owner
|
|
1172
|
+
self.tokens = tokens
|
|
1173
|
+
self.text = text
|
|
1174
|
+
self.line = line
|
|
1175
|
+
self.scope = scope
|
|
1176
|
+
self.pos = 0
|
|
1177
|
+
|
|
1178
|
+
def run(self) -> bool:
|
|
1179
|
+
value = self.parse_or()
|
|
1180
|
+
if self.pos != len(self.tokens):
|
|
1181
|
+
raise self.bad()
|
|
1182
|
+
return value
|
|
1183
|
+
|
|
1184
|
+
def bad(self):
|
|
1185
|
+
return self.o.err(f"invalid condition: '{self.text}'", self.line)
|
|
1186
|
+
|
|
1187
|
+
def peek(self) -> str | None:
|
|
1188
|
+
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
|
1189
|
+
|
|
1190
|
+
def parse_or(self) -> bool:
|
|
1191
|
+
v = self.parse_and()
|
|
1192
|
+
while self.peek() == "or":
|
|
1193
|
+
self.pos += 1
|
|
1194
|
+
r = self.parse_and()
|
|
1195
|
+
v = v or r
|
|
1196
|
+
return v
|
|
1197
|
+
|
|
1198
|
+
def parse_and(self) -> bool:
|
|
1199
|
+
v = self.parse_not()
|
|
1200
|
+
while self.peek() == "and":
|
|
1201
|
+
self.pos += 1
|
|
1202
|
+
r = self.parse_not()
|
|
1203
|
+
v = v and r
|
|
1204
|
+
return v
|
|
1205
|
+
|
|
1206
|
+
def parse_not(self) -> bool:
|
|
1207
|
+
if self.peek() == "not":
|
|
1208
|
+
self.pos += 1
|
|
1209
|
+
return not self.parse_not()
|
|
1210
|
+
if self.peek() == "(":
|
|
1211
|
+
self.pos += 1
|
|
1212
|
+
v = self.parse_or()
|
|
1213
|
+
if self.peek() != ")":
|
|
1214
|
+
raise self.bad()
|
|
1215
|
+
self.pos += 1
|
|
1216
|
+
return v
|
|
1217
|
+
return self.parse_compare()
|
|
1218
|
+
|
|
1219
|
+
def parse_compare(self) -> bool:
|
|
1220
|
+
left = self.operand()
|
|
1221
|
+
op = self.peek()
|
|
1222
|
+
if op is not None and op in COMPARATORS:
|
|
1223
|
+
self.pos += 1
|
|
1224
|
+
right = self.operand()
|
|
1225
|
+
return self.compare(op, left, right)
|
|
1226
|
+
if isinstance(left, bool):
|
|
1227
|
+
return left
|
|
1228
|
+
raise self.o.err(f"invalid condition: '{self.text}'", self.line, None,
|
|
1229
|
+
"a name alone must be true or false")
|
|
1230
|
+
|
|
1231
|
+
def operand(self):
|
|
1232
|
+
token = self.peek()
|
|
1233
|
+
if token is None:
|
|
1234
|
+
raise self.bad()
|
|
1235
|
+
self.pos += 1
|
|
1236
|
+
if token == "true":
|
|
1237
|
+
return True
|
|
1238
|
+
if token == "false":
|
|
1239
|
+
return False
|
|
1240
|
+
if token.startswith('"'):
|
|
1241
|
+
return token[1:len(token) - 1]
|
|
1242
|
+
if token[0].isdigit() or token[0] == "-":
|
|
1243
|
+
return float(token)
|
|
1244
|
+
if token in ("and", "or", "not", "(", ")") or token in COMPARATORS:
|
|
1245
|
+
raise self.bad()
|
|
1246
|
+
return self.o._lookup(token, self.line, self.scope)
|
|
1247
|
+
|
|
1248
|
+
def compare(self, op: str, a, b) -> bool:
|
|
1249
|
+
x = float(a) if _is_num(a) else None
|
|
1250
|
+
y = float(b) if _is_num(b) else None
|
|
1251
|
+
if x is not None and y is not None:
|
|
1252
|
+
return {"==": x == y, "!=": x != y, "<": x < y, ">": x > y, "<=": x <= y}.get(op, x >= y)
|
|
1253
|
+
if op == "==":
|
|
1254
|
+
return _equals(a, b)
|
|
1255
|
+
if op == "!=":
|
|
1256
|
+
return not _equals(a, b)
|
|
1257
|
+
if isinstance(a, str) and isinstance(b, str):
|
|
1258
|
+
if op == "<":
|
|
1259
|
+
return a < b
|
|
1260
|
+
if op == ">":
|
|
1261
|
+
return a > b
|
|
1262
|
+
if op == "<=":
|
|
1263
|
+
return a <= b
|
|
1264
|
+
return a >= b
|
|
1265
|
+
raise self.o.err(f"invalid condition: '{self.text}'", self.line, None,
|
|
1266
|
+
f"these values cannot be compared with '{op}'")
|