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.
@@ -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