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,248 @@
|
|
|
1
|
+
"""Contract of the host and render session (port of LayoutHost.kt).
|
|
2
|
+
|
|
3
|
+
`next_event` is asynchronous (`suspend` in Kotlin): in Kivy it runs on asyncio.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from . import scopes as Scopes
|
|
11
|
+
from .differ import Change, diff
|
|
12
|
+
from .layout_engine import LayoutEngine, LayoutWarning
|
|
13
|
+
from .tree import Native, native_children
|
|
14
|
+
|
|
15
|
+
# Key of the event the host sends when the system back button is pressed.
|
|
16
|
+
BACK_EVENT_KEY = "\u0000back"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class LayoutEvent:
|
|
21
|
+
key: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LayoutHost(ABC):
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def apply_changes(self, changes: list[Change]) -> None: ...
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def next_event(self) -> LayoutEvent | None: ...
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def field_text(self, id: str) -> str | None: ...
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def field_checked(self, id: str) -> bool | None: ...
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def set_value(self, id: str, text: str) -> None: ...
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def save(self, key: str, text: str) -> None: ...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def load(self, key: str) -> str | None: ...
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def exists(self, key: str) -> bool: ...
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def delete(self, key: str) -> None: ...
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def close(self) -> None: ...
|
|
54
|
+
|
|
55
|
+
def show_error(self, text: str) -> None:
|
|
56
|
+
"""Shows an error in place of the screen (optional)."""
|
|
57
|
+
|
|
58
|
+
def post_back(self) -> None:
|
|
59
|
+
"""Wakes the program with the back-button event (optional)."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class FilledMarkup:
|
|
64
|
+
html: str
|
|
65
|
+
line: int
|
|
66
|
+
variables: dict[str, str] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class PreparedRender:
|
|
71
|
+
"""Result of `LayoutSession.prepare`: plain data, it can be built on any thread."""
|
|
72
|
+
root: Native
|
|
73
|
+
by_key: dict[str, Native]
|
|
74
|
+
by_id: dict[str, Native]
|
|
75
|
+
changes: list[Change]
|
|
76
|
+
warnings: list[LayoutWarning]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LayoutSession:
|
|
80
|
+
def __init__(self, host: LayoutHost):
|
|
81
|
+
self._host = host
|
|
82
|
+
self._tree: Native | None = None
|
|
83
|
+
self._by_key: dict[str, Native] = {}
|
|
84
|
+
self._by_id: dict[str, Native] = {}
|
|
85
|
+
self._last_event: LayoutEvent | None = None
|
|
86
|
+
self._clicked_index = -1
|
|
87
|
+
self._closed = False
|
|
88
|
+
self._items: dict[str, object] = {}
|
|
89
|
+
self.warnings: list[LayoutWarning] = []
|
|
90
|
+
|
|
91
|
+
def prepare(self, m: FilledMarkup) -> PreparedRender:
|
|
92
|
+
"""Heavy part: builds the tree, indexes it and diffs it against the one on screen. Does not touch the host."""
|
|
93
|
+
r = LayoutEngine.build(m.html, m.line, m.variables)
|
|
94
|
+
changes = diff(self._tree, r.root)
|
|
95
|
+
by_key: dict[str, Native] = {}
|
|
96
|
+
self._index(r.root, by_key)
|
|
97
|
+
return PreparedRender(r.root, by_key, r.by_id, changes, r.warnings)
|
|
98
|
+
|
|
99
|
+
async def commit(self, p: PreparedRender) -> bool:
|
|
100
|
+
"""Light part: applies the changes to the views and waits for the next event (on the thread of the views)."""
|
|
101
|
+
if self._closed:
|
|
102
|
+
return False
|
|
103
|
+
for a in p.warnings:
|
|
104
|
+
if not any(w.message == a.message and w.line == a.line for w in self.warnings):
|
|
105
|
+
self.warnings.append(a)
|
|
106
|
+
if p.changes:
|
|
107
|
+
self._host.apply_changes(p.changes)
|
|
108
|
+
self._tree = p.root
|
|
109
|
+
self._by_key = p.by_key
|
|
110
|
+
self._by_id = p.by_id
|
|
111
|
+
self._last_event = None
|
|
112
|
+
self._clicked_index = -1
|
|
113
|
+
|
|
114
|
+
event = await self._host.next_event()
|
|
115
|
+
if event is None:
|
|
116
|
+
self._closed = True
|
|
117
|
+
self._tree = None
|
|
118
|
+
self._host.close()
|
|
119
|
+
return False
|
|
120
|
+
self._last_event = event
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
async def render(self, m: FilledMarkup) -> bool:
|
|
124
|
+
return await self.commit(self.prepare(m))
|
|
125
|
+
|
|
126
|
+
# ---- identity inside a `for` (section 5.7)
|
|
127
|
+
def set_scope_items(self, items: dict[str, object]) -> None:
|
|
128
|
+
self._items = items
|
|
129
|
+
|
|
130
|
+
def _tapped_scope(self) -> str | None:
|
|
131
|
+
if self._last_event is None:
|
|
132
|
+
return None
|
|
133
|
+
n = self._by_key.get(self._last_event.key)
|
|
134
|
+
return n.scope if n is not None else None
|
|
135
|
+
|
|
136
|
+
def clicked_item(self, name: str):
|
|
137
|
+
path = self._tapped_scope()
|
|
138
|
+
if path is None:
|
|
139
|
+
raise RuntimeError(f"clicked('{name}'): the tapped element is not inside a 'for'")
|
|
140
|
+
segments = path.split("/")
|
|
141
|
+
for i in range(len(segments) - 1, -1, -1):
|
|
142
|
+
if Scopes.variable_of(segments[i]) == name:
|
|
143
|
+
prefix = "/".join(segments[:i + 1])
|
|
144
|
+
if prefix not in self._items:
|
|
145
|
+
break
|
|
146
|
+
return self._items[prefix]
|
|
147
|
+
raise RuntimeError(f"clicked('{name}'): the tapped element is not inside 'for {name} in ...'")
|
|
148
|
+
|
|
149
|
+
def scoped_id(self, id: str, given: list | None = None) -> str | None:
|
|
150
|
+
given = given or []
|
|
151
|
+
if not given:
|
|
152
|
+
if id in self._by_id:
|
|
153
|
+
return id
|
|
154
|
+
prefix = id + Scopes.SEP
|
|
155
|
+
sample = next((k for k in self._by_id if k.startswith(prefix)), None)
|
|
156
|
+
if sample is None:
|
|
157
|
+
return None
|
|
158
|
+
segs = sample[len(prefix):].split("/")
|
|
159
|
+
vars_ = ", ".join(Scopes.variable_of(s) for s in segs)
|
|
160
|
+
raise RuntimeError(f"'{id}' is repeated: give {len(segs)} item(s), one for each of: {vars_}")
|
|
161
|
+
prefix = id + Scopes.SEP
|
|
162
|
+
candidates = [k for k in self._by_id if k.startswith(prefix)]
|
|
163
|
+
if not candidates:
|
|
164
|
+
return None
|
|
165
|
+
for c in candidates:
|
|
166
|
+
segments = c[len(prefix):].split("/")
|
|
167
|
+
if len(segments) != len(given):
|
|
168
|
+
continue
|
|
169
|
+
ok = True
|
|
170
|
+
path = ""
|
|
171
|
+
for k, seg in enumerate(segments):
|
|
172
|
+
path = seg if k == 0 else path + "/" + seg
|
|
173
|
+
if path not in self._items or not Scopes.same_item(
|
|
174
|
+
given[k], self._items[path], Scopes.token_of(seg)):
|
|
175
|
+
ok = False
|
|
176
|
+
break
|
|
177
|
+
if ok:
|
|
178
|
+
return c
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def click(self, target: str) -> bool:
|
|
182
|
+
ev = self._last_event
|
|
183
|
+
if ev is None:
|
|
184
|
+
return False
|
|
185
|
+
node = self._by_key.get(ev.key)
|
|
186
|
+
if node is None:
|
|
187
|
+
return False
|
|
188
|
+
if target.startswith("."):
|
|
189
|
+
cls = target[1:]
|
|
190
|
+
if cls not in node.classes:
|
|
191
|
+
return False
|
|
192
|
+
all_: list[Native] = []
|
|
193
|
+
if self._tree is not None:
|
|
194
|
+
self._collect_class(self._tree, cls, all_)
|
|
195
|
+
self._clicked_index = next((i for i, n in enumerate(all_) if n.key == node.key), -1)
|
|
196
|
+
return True
|
|
197
|
+
nid = node.id
|
|
198
|
+
if nid == target or (nid is not None and node.scope is not None
|
|
199
|
+
and nid.split(Scopes.SEP, 1)[0] == target):
|
|
200
|
+
self._clicked_index = -1
|
|
201
|
+
return True
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
def click_index(self) -> int:
|
|
205
|
+
return self._clicked_index
|
|
206
|
+
|
|
207
|
+
def is_back(self) -> bool:
|
|
208
|
+
return self._last_event is not None and self._last_event.key == BACK_EVENT_KEY
|
|
209
|
+
|
|
210
|
+
def reset(self) -> None:
|
|
211
|
+
"""Forgets the tree on screen: the next render builds everything from scratch (the screen changed)."""
|
|
212
|
+
self._tree = None
|
|
213
|
+
|
|
214
|
+
def field_text(self, id: str, given: list | None = None) -> str | None:
|
|
215
|
+
s = self.scoped_id(id, given)
|
|
216
|
+
return self._host.field_text(s) if s is not None else None
|
|
217
|
+
|
|
218
|
+
def field_checked(self, id: str, given: list | None = None) -> bool | None:
|
|
219
|
+
s = self.scoped_id(id, given)
|
|
220
|
+
return self._host.field_checked(s) if s is not None else None
|
|
221
|
+
|
|
222
|
+
def set_value(self, id: str, text: str, given: list | None = None) -> None:
|
|
223
|
+
s = self.scoped_id(id, given)
|
|
224
|
+
if s is not None:
|
|
225
|
+
self._host.set_value(s, text)
|
|
226
|
+
|
|
227
|
+
def save(self, key: str, text: str) -> None:
|
|
228
|
+
self._host.save(key, text)
|
|
229
|
+
|
|
230
|
+
def load(self, key: str) -> str | None:
|
|
231
|
+
return self._host.load(key)
|
|
232
|
+
|
|
233
|
+
def exists(self, key: str) -> bool:
|
|
234
|
+
return self._host.exists(key)
|
|
235
|
+
|
|
236
|
+
def delete(self, key: str) -> None:
|
|
237
|
+
self._host.delete(key)
|
|
238
|
+
|
|
239
|
+
def _index(self, n: Native, result: dict[str, Native]) -> None:
|
|
240
|
+
result[n.key] = n
|
|
241
|
+
for f in native_children(n):
|
|
242
|
+
self._index(f, result)
|
|
243
|
+
|
|
244
|
+
def _collect_class(self, n: Native, cls: str, result: list[Native]) -> None:
|
|
245
|
+
if cls in n.classes:
|
|
246
|
+
result.append(n)
|
|
247
|
+
for f in native_children(n):
|
|
248
|
+
self._collect_class(f, cls, result)
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Program side: the verbs of the spec (section 8) on top of the engine.
|
|
2
|
+
|
|
3
|
+
The part of `NativeView` that does not depend on the platform. Each host (Kivy/android, Kivy/desktop)
|
|
4
|
+
provides the `LayoutHost`, the `loader` and the dark theme.
|
|
5
|
+
|
|
6
|
+
Two styles, as in the spec:
|
|
7
|
+
- screen functions: `def panel(): s = state({...}); if click("add"): ...; return view("panel.nl", s)`
|
|
8
|
+
- base layer: `while await ui.render(screen, data): ...`
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextvars
|
|
14
|
+
import traceback
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
from .errors import NativeLayoutError
|
|
19
|
+
from .expander import Expander
|
|
20
|
+
from .layout_host import FilledMarkup, LayoutHost, LayoutSession
|
|
21
|
+
|
|
22
|
+
MAX_HOPS = 8
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Page:
|
|
26
|
+
"""What a screen function returns: `view(...)`, `go(...)` or `go_back()`."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ViewPage(Page):
|
|
30
|
+
def __init__(self, screen: "Screen", data: dict):
|
|
31
|
+
self.screen = screen
|
|
32
|
+
self.data = data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GoPage(Page):
|
|
36
|
+
def __init__(self, next_: Callable[[], Page], root: bool = False):
|
|
37
|
+
self.next = next_
|
|
38
|
+
self.root = root
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BackPage(Page):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_BACK = BackPage()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Screen:
|
|
49
|
+
def __init__(self, name: str, source: str):
|
|
50
|
+
self.name = name
|
|
51
|
+
self.source = source
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_current: contextvars.ContextVar["NativeViewBase | None"] = contextvars.ContextVar(
|
|
55
|
+
"nativelayout_current", default=None)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class NativeViewBase:
|
|
59
|
+
def __init__(self, host: LayoutHost, loader: Callable[[str], str | None],
|
|
60
|
+
app: Callable[[], Page] | None = None, body=None,
|
|
61
|
+
is_dark: Callable[[], bool] = lambda: False):
|
|
62
|
+
self._host = host
|
|
63
|
+
self._loader = loader
|
|
64
|
+
self._app = app
|
|
65
|
+
self._body = body
|
|
66
|
+
self._is_dark = is_dark
|
|
67
|
+
self._session = LayoutSession(host)
|
|
68
|
+
self._worker = ThreadPoolExecutor(max_workers=1, thread_name_prefix="nativelayout-prepare")
|
|
69
|
+
self._closed = False
|
|
70
|
+
self._screens: dict[str, Screen] = {}
|
|
71
|
+
self._slots: list = []
|
|
72
|
+
self._slot_index = 0
|
|
73
|
+
self._fresh = False
|
|
74
|
+
self._history: list[Callable[[], Page]] = []
|
|
75
|
+
self._back_handled = False
|
|
76
|
+
self._last_shown: ViewPage | None = None
|
|
77
|
+
# called when the back button is pressed on the first screen and the program did not handle it
|
|
78
|
+
self.on_exit: Callable[[], None] | None = None
|
|
79
|
+
|
|
80
|
+
# ---- base layer
|
|
81
|
+
def screen(self, name: str) -> Screen:
|
|
82
|
+
source = self._loader(name)
|
|
83
|
+
if source is None:
|
|
84
|
+
raise ValueError(f"screen not found: {name}")
|
|
85
|
+
return Screen(name, source)
|
|
86
|
+
|
|
87
|
+
async def render(self, screen: Screen, data: dict | None = None) -> bool:
|
|
88
|
+
if not isinstance(screen, Screen):
|
|
89
|
+
raise TypeError("render: expected a screen")
|
|
90
|
+
if self._closed:
|
|
91
|
+
return False
|
|
92
|
+
data = data if data is not None else {}
|
|
93
|
+
|
|
94
|
+
def work():
|
|
95
|
+
ex = Expander.expand(screen.source, data, lambda n: self._loader(n), self._is_dark())
|
|
96
|
+
return self._session.prepare(FilledMarkup(ex.html, 1)), ex
|
|
97
|
+
|
|
98
|
+
loop = asyncio.get_running_loop()
|
|
99
|
+
prepared, expanded = await loop.run_in_executor(self._worker, work)
|
|
100
|
+
self._session.set_scope_items(expanded.items)
|
|
101
|
+
for w in expanded.warnings:
|
|
102
|
+
if not any(x.message == w.message and x.line == w.line for x in self._session.warnings):
|
|
103
|
+
self._session.warnings.append(w)
|
|
104
|
+
return await self._session.commit(prepared)
|
|
105
|
+
|
|
106
|
+
# ---- screen functions
|
|
107
|
+
def state(self, initial):
|
|
108
|
+
i = self._slot_index
|
|
109
|
+
self._slot_index += 1
|
|
110
|
+
if i == len(self._slots):
|
|
111
|
+
self._slots.append(initial() if callable(initial) else initial)
|
|
112
|
+
return self._slots[i]
|
|
113
|
+
|
|
114
|
+
def view(self, name: str, data: dict | None = None) -> Page:
|
|
115
|
+
scr = self._screens.get(name)
|
|
116
|
+
if scr is None:
|
|
117
|
+
scr = self._screens[name] = self.screen(name)
|
|
118
|
+
return ViewPage(scr, data if data is not None else {})
|
|
119
|
+
|
|
120
|
+
def go(self, next_: Callable[[], Page]) -> Page:
|
|
121
|
+
return GoPage(next_)
|
|
122
|
+
|
|
123
|
+
def go_root(self, next_: Callable[[], Page]) -> Page:
|
|
124
|
+
return GoPage(next_, root=True)
|
|
125
|
+
|
|
126
|
+
def go_back(self) -> Page:
|
|
127
|
+
return _BACK
|
|
128
|
+
|
|
129
|
+
def can_go_back(self) -> bool:
|
|
130
|
+
return bool(self._history)
|
|
131
|
+
|
|
132
|
+
def back(self) -> bool:
|
|
133
|
+
if self._fresh or not self._session.is_back():
|
|
134
|
+
return False
|
|
135
|
+
self._back_handled = True
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
def press_back(self) -> None:
|
|
139
|
+
if not self._closed:
|
|
140
|
+
self._host.post_back()
|
|
141
|
+
|
|
142
|
+
async def run(self) -> None:
|
|
143
|
+
"""Runs the program until the window closes. An error is shown in place of the screen."""
|
|
144
|
+
try:
|
|
145
|
+
if self._app is not None:
|
|
146
|
+
await self._run_app(self._app)
|
|
147
|
+
elif self._body is not None:
|
|
148
|
+
token = _current.set(self)
|
|
149
|
+
try:
|
|
150
|
+
await self._body(self)
|
|
151
|
+
finally:
|
|
152
|
+
_current.reset(token)
|
|
153
|
+
except Exception as error:
|
|
154
|
+
traceback.print_exc()
|
|
155
|
+
self._host.show_error(error.format() if isinstance(error, NativeLayoutError)
|
|
156
|
+
else (str(error) or repr(error)))
|
|
157
|
+
|
|
158
|
+
async def _run_app(self, app: Callable[[], Page]) -> None:
|
|
159
|
+
current = app
|
|
160
|
+
hops = 0
|
|
161
|
+
while not self._closed:
|
|
162
|
+
self._slot_index = 0
|
|
163
|
+
self._back_handled = False
|
|
164
|
+
token = _current.set(self)
|
|
165
|
+
try:
|
|
166
|
+
page = current()
|
|
167
|
+
finally:
|
|
168
|
+
_current.reset(token)
|
|
169
|
+
if not self._fresh and not self._back_handled and self._session.is_back():
|
|
170
|
+
if self._history:
|
|
171
|
+
page = _BACK
|
|
172
|
+
elif self.on_exit is not None:
|
|
173
|
+
self.on_exit()
|
|
174
|
+
if isinstance(page, BackPage) and not self._history:
|
|
175
|
+
# nothing to go back to: the screen stays as it is
|
|
176
|
+
if self.on_exit is not None:
|
|
177
|
+
self.on_exit()
|
|
178
|
+
if self._last_shown is None:
|
|
179
|
+
raise RuntimeError("goBack: there is no screen to return to")
|
|
180
|
+
page = self._last_shown
|
|
181
|
+
if isinstance(page, GoPage):
|
|
182
|
+
hops += 1
|
|
183
|
+
if hops > MAX_HOPS:
|
|
184
|
+
raise RuntimeError("go: too many jumps without showing a screen")
|
|
185
|
+
if page.root:
|
|
186
|
+
self._history.clear()
|
|
187
|
+
else:
|
|
188
|
+
self._history.append(current)
|
|
189
|
+
current = page.next
|
|
190
|
+
self._last_shown = None
|
|
191
|
+
self._slots.clear()
|
|
192
|
+
self._fresh = True
|
|
193
|
+
self._session.reset()
|
|
194
|
+
elif isinstance(page, BackPage):
|
|
195
|
+
hops += 1
|
|
196
|
+
if hops > MAX_HOPS:
|
|
197
|
+
raise RuntimeError("go: too many jumps without showing a screen")
|
|
198
|
+
current = self._history.pop()
|
|
199
|
+
self._last_shown = None
|
|
200
|
+
self._slots.clear()
|
|
201
|
+
self._fresh = True
|
|
202
|
+
self._session.reset()
|
|
203
|
+
elif isinstance(page, ViewPage):
|
|
204
|
+
hops = 0
|
|
205
|
+
self._last_shown = page
|
|
206
|
+
if not await self.render(page.screen, page.data):
|
|
207
|
+
return
|
|
208
|
+
self._fresh = False
|
|
209
|
+
else:
|
|
210
|
+
raise TypeError("a screen function must return view(...), go(...) or go_back()")
|
|
211
|
+
|
|
212
|
+
# ---- taps and fields (after a jump there is no tap yet: they answer "nothing")
|
|
213
|
+
def click(self, target: str) -> bool:
|
|
214
|
+
return (not self._fresh) and self._session.click(target)
|
|
215
|
+
|
|
216
|
+
def click_index(self) -> int:
|
|
217
|
+
return -1 if self._fresh else self._session.click_index()
|
|
218
|
+
|
|
219
|
+
def clicked(self, name: str):
|
|
220
|
+
return None if self._fresh else self._session.clicked_item(name)
|
|
221
|
+
|
|
222
|
+
def value(self, id: str, *items) -> str:
|
|
223
|
+
if self._fresh:
|
|
224
|
+
return ""
|
|
225
|
+
r = self._session.field_text(id, list(items))
|
|
226
|
+
return r if r is not None else ""
|
|
227
|
+
|
|
228
|
+
def checked(self, id: str, *items) -> bool:
|
|
229
|
+
if self._fresh:
|
|
230
|
+
return False
|
|
231
|
+
r = self._session.field_checked(id, list(items))
|
|
232
|
+
return r if r is not None else False
|
|
233
|
+
|
|
234
|
+
def set_value(self, id: str, *args) -> None:
|
|
235
|
+
"""`set_value(id, text)` or `set_value(id, item..., text)`: the last argument is the text."""
|
|
236
|
+
if not args or not isinstance(args[-1], str):
|
|
237
|
+
raise TypeError("set_value(id, item..., text): the last argument must be the text")
|
|
238
|
+
self._session.set_value(id, args[-1], list(args[:-1]))
|
|
239
|
+
|
|
240
|
+
def save(self, key: str, text: str) -> None:
|
|
241
|
+
self._session.save(key, text)
|
|
242
|
+
|
|
243
|
+
def load(self, key: str) -> str | None:
|
|
244
|
+
return self._session.load(key)
|
|
245
|
+
|
|
246
|
+
def exists(self, key: str) -> bool:
|
|
247
|
+
return self._session.exists(key)
|
|
248
|
+
|
|
249
|
+
def delete(self, key: str) -> None:
|
|
250
|
+
self._session.delete(key)
|
|
251
|
+
|
|
252
|
+
def close(self) -> None:
|
|
253
|
+
self._closed = True
|
|
254
|
+
self._worker.shutdown(wait=False)
|
|
255
|
+
self._host.close()
|
|
256
|
+
|
|
257
|
+
def __enter__(self):
|
|
258
|
+
return self
|
|
259
|
+
|
|
260
|
+
def __exit__(self, *exc):
|
|
261
|
+
self.close()
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---- free verbs, bound to the NativeView that is calling the screen function
|
|
266
|
+
def _ui() -> NativeViewBase:
|
|
267
|
+
ui = _current.get()
|
|
268
|
+
if ui is None:
|
|
269
|
+
raise RuntimeError("this verb can only be used inside a screen function")
|
|
270
|
+
return ui
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def screen(name): return _ui().screen(name)
|
|
274
|
+
def state(initial): return _ui().state(initial)
|
|
275
|
+
def view(name, data=None): return _ui().view(name, data)
|
|
276
|
+
def go(next_): return _ui().go(next_)
|
|
277
|
+
def go_root(next_): return _ui().go_root(next_)
|
|
278
|
+
def go_back(): return _ui().go_back()
|
|
279
|
+
def can_go_back(): return _ui().can_go_back()
|
|
280
|
+
def back(): return _ui().back()
|
|
281
|
+
def click(target): return _ui().click(target)
|
|
282
|
+
def click_index(): return _ui().click_index()
|
|
283
|
+
def clicked(name): return _ui().clicked(name)
|
|
284
|
+
def value(id, *items): return _ui().value(id, *items)
|
|
285
|
+
def checked(id, *items): return _ui().checked(id, *items)
|
|
286
|
+
def set_value(id, *args): return _ui().set_value(id, *args)
|
|
287
|
+
def save(key, text): return _ui().save(key, text)
|
|
288
|
+
def load(key): return _ui().load(key)
|
|
289
|
+
def exists(key): return _ui().exists(key)
|
|
290
|
+
def delete(key): return _ui().delete(key)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Identity of the repetitions of a `for` (port of Scopes.kt, section 5.7)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
SEP = "~"
|
|
5
|
+
_KEY_FIELDS = ("key", "id")
|
|
6
|
+
_HEX = "0123456789ABCDEF"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def key_of_item(item) -> str | None:
|
|
10
|
+
"""Automatic key of an item: its field `key`, or else its field `id`. Only text and whole numbers."""
|
|
11
|
+
if not isinstance(item, dict):
|
|
12
|
+
return None
|
|
13
|
+
for field in _KEY_FIELDS:
|
|
14
|
+
v = item.get(field)
|
|
15
|
+
if isinstance(v, str):
|
|
16
|
+
t = v.strip()
|
|
17
|
+
if t:
|
|
18
|
+
return t
|
|
19
|
+
elif isinstance(v, int) and not isinstance(v, bool):
|
|
20
|
+
return str(v)
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def encode(s: str) -> str:
|
|
25
|
+
"""Letters, digits, `_`, `-` and `.` stay; the rest becomes %XX (`/`, `:` and `~` never appear)."""
|
|
26
|
+
out = []
|
|
27
|
+
for c in s.encode("utf-8"):
|
|
28
|
+
plain = (97 <= c <= 122) or (65 <= c <= 90) or (48 <= c <= 57) or c in (95, 45, 46)
|
|
29
|
+
out.append(chr(c) if plain else "%" + _HEX[c >> 4] + _HEX[c & 15])
|
|
30
|
+
return "".join(out)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def token(key: str | None, index: int) -> str:
|
|
34
|
+
"""Token of a repetition: its key, or `%p` + the position when there is no key."""
|
|
35
|
+
return encode(key) if key is not None else f"%p{index}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def variable_of(segment: str) -> str:
|
|
39
|
+
return segment.split(":", 1)[0]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def token_of(segment: str) -> str:
|
|
43
|
+
return segment.split(":", 1)[1] if ":" in segment else segment
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def same_item(given, held, tok: str) -> bool:
|
|
47
|
+
if given is held or given == held:
|
|
48
|
+
return True
|
|
49
|
+
key = key_of_item(given)
|
|
50
|
+
if key is None:
|
|
51
|
+
return False
|
|
52
|
+
return encode(key) == tok
|