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,619 @@
|
|
|
1
|
+
"""Kivy host: applies the changes of the native tree to the widgets and hands the events to the program."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import base64
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
|
|
11
|
+
from kivy.clock import Clock
|
|
12
|
+
from kivy.core.image import Image as CoreImage
|
|
13
|
+
from kivy.metrics import dp, sp
|
|
14
|
+
from kivy.uix.label import Label
|
|
15
|
+
from kivy.uix.relativelayout import RelativeLayout
|
|
16
|
+
from kivy.uix.scrollview import ScrollView
|
|
17
|
+
|
|
18
|
+
from nativelayout.core import icons as IconSet
|
|
19
|
+
from nativelayout.core.differ import (Create, Remove, Replace, SetItems, SetRoot, UpdateAttributes,
|
|
20
|
+
UpdateStyle, UpdateText)
|
|
21
|
+
from nativelayout.core.layout_host import BACK_EVENT_KEY, LayoutEvent, LayoutHost
|
|
22
|
+
from nativelayout.core.tree import (Align, Dim, Direction, Dock, FieldType, Justify, Length, Native,
|
|
23
|
+
NativeBox, NativeButton, NativeCheckbox, NativeField, NativeImage,
|
|
24
|
+
NativeItem, NativeList, NativeStyle, NativeText, Percent, Position,
|
|
25
|
+
Span, native_children)
|
|
26
|
+
|
|
27
|
+
from . import draw, widgets
|
|
28
|
+
from .flex import LNode, Metrics, lay_node, layout, layout_absolute
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Entry:
|
|
32
|
+
"""A node of the native tree with its widget."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, host: "KivyLayoutHost", node: Native, parent: "Entry | None"):
|
|
35
|
+
self.host = host
|
|
36
|
+
self.node = node
|
|
37
|
+
self.style: NativeStyle = node.style
|
|
38
|
+
self.parent = parent
|
|
39
|
+
self.children: list[Entry] = []
|
|
40
|
+
self.spans: list[Span] | None = None
|
|
41
|
+
self.texture = None
|
|
42
|
+
self.widget = None
|
|
43
|
+
self.lnode: LNode | None = None
|
|
44
|
+
self.inner: LNode | None = None
|
|
45
|
+
self.marker_box = None
|
|
46
|
+
|
|
47
|
+
# ---- properties used by the widgets
|
|
48
|
+
@property
|
|
49
|
+
def is_layer(self) -> bool:
|
|
50
|
+
return isinstance(self.node, NativeBox) and self.node.overlay
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_header(self) -> bool:
|
|
54
|
+
return isinstance(self.node, NativeBox) and self.node.header
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def is_footer(self) -> bool:
|
|
58
|
+
return isinstance(self.node, NativeBox) and self.node.footer
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_special(self) -> bool:
|
|
62
|
+
return self.is_layer or self.is_header or self.is_footer
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_absolute(self) -> bool:
|
|
66
|
+
"""`position: absolute`: out of the flow of its parent, placed by top/right/bottom/left."""
|
|
67
|
+
return self.style.position == Position.ABSOLUTE and not self.is_special
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_button(self) -> bool:
|
|
71
|
+
return isinstance(self.node, NativeButton)
|
|
72
|
+
|
|
73
|
+
def interactive(self) -> bool:
|
|
74
|
+
return self.node.id is not None or bool(self.node.classes) or isinstance(self.node, NativeButton)
|
|
75
|
+
|
|
76
|
+
def is_disabled(self) -> bool:
|
|
77
|
+
n = self.node
|
|
78
|
+
return isinstance(n, (NativeButton, NativeField, NativeCheckbox)) and n.disabled
|
|
79
|
+
|
|
80
|
+
def effective_opacity(self) -> float:
|
|
81
|
+
a = self.style.opacity
|
|
82
|
+
if self.is_disabled():
|
|
83
|
+
d = self.style.states.disabled.opacity if (self.style.states and self.style.states.disabled) else None
|
|
84
|
+
a *= d if d is not None else 0.5
|
|
85
|
+
return a
|
|
86
|
+
|
|
87
|
+
def markup(self) -> str:
|
|
88
|
+
n = self.node
|
|
89
|
+
px = self.host.px
|
|
90
|
+
if isinstance(n, NativeText):
|
|
91
|
+
return widgets.markup_of(self.spans if self.spans is not None else n.spans, px)
|
|
92
|
+
if isinstance(n, NativeButton):
|
|
93
|
+
st = self.style
|
|
94
|
+
return widgets.markup_of([Span(n.label, st.bold, st.text_color, st.font_size, st.italic,
|
|
95
|
+
st.underline, st.strike)], px)
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
def icon_name(self) -> str | None:
|
|
99
|
+
n = self.node
|
|
100
|
+
if isinstance(n, NativeImage):
|
|
101
|
+
name = IconSet.name_of(n.source)
|
|
102
|
+
if name is not None and IconSet.shapes_of(name) is not None:
|
|
103
|
+
return name
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class NLArea(RelativeLayout):
|
|
108
|
+
"""The widget the program puts on its screen: it holds the header, the scroll, the footer and the layers."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class KivyLayoutHost(LayoutHost):
|
|
112
|
+
def __init__(self, area: NLArea, image_resolver=None, store_path: str | None = None):
|
|
113
|
+
self.area = area
|
|
114
|
+
self.image_resolver = image_resolver or (lambda source: None)
|
|
115
|
+
self._store_path = store_path
|
|
116
|
+
self._store: dict | None = None
|
|
117
|
+
|
|
118
|
+
self.entries: dict[str, Entry] = {}
|
|
119
|
+
self._bg_cache: dict[str, object] = {}
|
|
120
|
+
self.root: Entry | None = None
|
|
121
|
+
self._structure_dirty = True
|
|
122
|
+
self._scheduled = False
|
|
123
|
+
self._closed = False
|
|
124
|
+
|
|
125
|
+
self._queue: list[LayoutEvent] = []
|
|
126
|
+
self._pending: asyncio.Future | None = None
|
|
127
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
128
|
+
|
|
129
|
+
self.scroll = ScrollView(do_scroll_x=False, bar_width=dp(3), size_hint=(None, None))
|
|
130
|
+
self.content = RelativeLayout(size_hint=(None, None))
|
|
131
|
+
self.scroll.add_widget(self.content)
|
|
132
|
+
self.header_layer = RelativeLayout(size_hint=(None, None))
|
|
133
|
+
self.footer_layer = RelativeLayout(size_hint=(None, None))
|
|
134
|
+
self.float_layer = RelativeLayout(size_hint=(None, None))
|
|
135
|
+
self.overlay_layer = RelativeLayout(size_hint=(None, None))
|
|
136
|
+
for w in (self.scroll, self.header_layer, self.footer_layer, self.float_layer, self.overlay_layer):
|
|
137
|
+
area.add_widget(w)
|
|
138
|
+
area.bind(size=lambda *_: self.schedule_layout())
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------- units
|
|
141
|
+
@property
|
|
142
|
+
def metrics(self) -> Metrics:
|
|
143
|
+
d = dp(1)
|
|
144
|
+
return Metrics(d, sp(1) / d)
|
|
145
|
+
|
|
146
|
+
def px(self, length: Length | None) -> float:
|
|
147
|
+
return self.metrics.px(length)
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------- LayoutHost
|
|
150
|
+
def apply_changes(self, changes) -> None:
|
|
151
|
+
if self._closed:
|
|
152
|
+
return
|
|
153
|
+
for a in changes:
|
|
154
|
+
if isinstance(a, SetRoot):
|
|
155
|
+
self._set_root(a.node)
|
|
156
|
+
elif isinstance(a, Create):
|
|
157
|
+
parent = self.entries.get(a.parent_key)
|
|
158
|
+
if parent is not None:
|
|
159
|
+
child = self._build(a.node, parent)
|
|
160
|
+
self._insert(parent, child, a.position)
|
|
161
|
+
elif isinstance(a, Remove):
|
|
162
|
+
self._remove(a.key)
|
|
163
|
+
elif isinstance(a, Replace):
|
|
164
|
+
self._replace(a)
|
|
165
|
+
elif isinstance(a, UpdateText):
|
|
166
|
+
e = self.entries.get(a.key)
|
|
167
|
+
if e is not None:
|
|
168
|
+
e.spans = a.spans
|
|
169
|
+
elif isinstance(a, UpdateStyle):
|
|
170
|
+
e = self.entries.get(a.key)
|
|
171
|
+
if e is not None:
|
|
172
|
+
e.style = a.style
|
|
173
|
+
elif isinstance(a, UpdateAttributes):
|
|
174
|
+
e = self.entries.get(a.key)
|
|
175
|
+
if e is not None:
|
|
176
|
+
e.node = a.node
|
|
177
|
+
self._configure(e)
|
|
178
|
+
elif isinstance(a, SetItems):
|
|
179
|
+
e = self.entries.get(a.key)
|
|
180
|
+
if e is not None:
|
|
181
|
+
for c in e.children:
|
|
182
|
+
self._discard(c)
|
|
183
|
+
e.children = [self._build(n, e) for n in a.items]
|
|
184
|
+
self._structure_dirty = True
|
|
185
|
+
self.schedule_layout()
|
|
186
|
+
|
|
187
|
+
async def next_event(self) -> LayoutEvent | None:
|
|
188
|
+
if self._queue:
|
|
189
|
+
return self._queue.pop(0)
|
|
190
|
+
if self._closed:
|
|
191
|
+
return None
|
|
192
|
+
self._loop = asyncio.get_running_loop()
|
|
193
|
+
self._pending = self._loop.create_future()
|
|
194
|
+
return await self._pending
|
|
195
|
+
|
|
196
|
+
def _find(self, id: str) -> Entry | None:
|
|
197
|
+
return self.entries.get("#" + id)
|
|
198
|
+
|
|
199
|
+
def field_text(self, id: str) -> str | None:
|
|
200
|
+
e = self._find(id)
|
|
201
|
+
return e.widget.text if e is not None and isinstance(e.node, NativeField) else None
|
|
202
|
+
|
|
203
|
+
def field_checked(self, id: str) -> bool | None:
|
|
204
|
+
e = self._find(id)
|
|
205
|
+
return e.widget.active if e is not None and isinstance(e.node, NativeCheckbox) else None
|
|
206
|
+
|
|
207
|
+
def set_value(self, id: str, text: str) -> None:
|
|
208
|
+
e = self._find(id)
|
|
209
|
+
if e is None:
|
|
210
|
+
return
|
|
211
|
+
if isinstance(e.node, NativeField):
|
|
212
|
+
e.widget.text = text
|
|
213
|
+
e.widget.cursor = (len(text), 0) if "\n" not in text else e.widget.cursor
|
|
214
|
+
elif isinstance(e.node, NativeCheckbox):
|
|
215
|
+
e.widget.active = text.strip().lower() in ("1", "true", "on")
|
|
216
|
+
self.redraw_entry(e)
|
|
217
|
+
|
|
218
|
+
def show_error(self, text: str) -> None:
|
|
219
|
+
self._reset()
|
|
220
|
+
self.root = None
|
|
221
|
+
label = Label(text=text, color=(1, 0.3, 0.3, 1), halign="left", valign="top",
|
|
222
|
+
size_hint=(None, None), markup=False)
|
|
223
|
+
label.bind(width=lambda w, v: setattr(w, "text_size", (v - dp(24), None)))
|
|
224
|
+
label.bind(texture_size=lambda w, s: setattr(w, "height", s[1] + dp(24)))
|
|
225
|
+
label.width = self.area.width
|
|
226
|
+
self.content.clear_widgets()
|
|
227
|
+
self.content.add_widget(label)
|
|
228
|
+
self.scroll.size = self.area.size
|
|
229
|
+
self.scroll.pos = (0, 0)
|
|
230
|
+
self.content.size = (self.area.width, max(label.height, self.area.height))
|
|
231
|
+
label.pos = (0, self.content.height - label.height)
|
|
232
|
+
|
|
233
|
+
# ---- storage (JSON in the data folder of the app)
|
|
234
|
+
def _path(self) -> str:
|
|
235
|
+
if self._store_path:
|
|
236
|
+
return self._store_path
|
|
237
|
+
try:
|
|
238
|
+
from kivy.app import App
|
|
239
|
+
base = App.get_running_app().user_data_dir
|
|
240
|
+
except Exception:
|
|
241
|
+
base = os.getcwd()
|
|
242
|
+
return os.path.join(base, "nativelayout.json")
|
|
243
|
+
|
|
244
|
+
def _data(self) -> dict:
|
|
245
|
+
if self._store is None:
|
|
246
|
+
try:
|
|
247
|
+
with open(self._path(), "r", encoding="utf-8") as f:
|
|
248
|
+
self._store = json.load(f)
|
|
249
|
+
except Exception:
|
|
250
|
+
self._store = {}
|
|
251
|
+
return self._store
|
|
252
|
+
|
|
253
|
+
def _flush(self) -> None:
|
|
254
|
+
try:
|
|
255
|
+
path = self._path()
|
|
256
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
257
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
258
|
+
json.dump(self._data(), f, ensure_ascii=False)
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
def save(self, key: str, text: str) -> None:
|
|
263
|
+
self._data()[key] = text
|
|
264
|
+
self._flush()
|
|
265
|
+
|
|
266
|
+
def load(self, key: str) -> str | None:
|
|
267
|
+
return self._data().get(key)
|
|
268
|
+
|
|
269
|
+
def exists(self, key: str) -> bool:
|
|
270
|
+
return key in self._data()
|
|
271
|
+
|
|
272
|
+
def delete(self, key: str) -> None:
|
|
273
|
+
self._data().pop(key, None)
|
|
274
|
+
self._flush()
|
|
275
|
+
|
|
276
|
+
def close(self) -> None:
|
|
277
|
+
if self._closed:
|
|
278
|
+
return
|
|
279
|
+
self._closed = True
|
|
280
|
+
self._queue.clear()
|
|
281
|
+
self._reset()
|
|
282
|
+
pending, self._pending = self._pending, None
|
|
283
|
+
if pending is not None and not pending.done():
|
|
284
|
+
self._loop.call_soon_threadsafe(lambda: pending.done() or pending.set_result(None))
|
|
285
|
+
|
|
286
|
+
def post_back(self) -> None:
|
|
287
|
+
self.publish(BACK_EVENT_KEY)
|
|
288
|
+
|
|
289
|
+
def publish(self, key: str) -> None:
|
|
290
|
+
if self._closed:
|
|
291
|
+
return
|
|
292
|
+
event = LayoutEvent(key)
|
|
293
|
+
pending = self._pending
|
|
294
|
+
if pending is not None and not pending.done():
|
|
295
|
+
self._pending = None
|
|
296
|
+
self._loop.call_soon_threadsafe(lambda: pending.done() or pending.set_result(event))
|
|
297
|
+
else:
|
|
298
|
+
self._queue.append(event)
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------- model
|
|
301
|
+
def _reset(self) -> None:
|
|
302
|
+
self.entries.clear()
|
|
303
|
+
self.root = None
|
|
304
|
+
for layer in (self.content, self.header_layer, self.footer_layer, self.float_layer, self.overlay_layer):
|
|
305
|
+
layer.clear_widgets()
|
|
306
|
+
|
|
307
|
+
def _set_root(self, node: Native) -> None:
|
|
308
|
+
self._reset()
|
|
309
|
+
self.root = self._build(node, None)
|
|
310
|
+
self.content.opacity = 0.0
|
|
311
|
+
from kivy.animation import Animation
|
|
312
|
+
Animation(opacity=1.0, duration=0.22, t="out_quad").start(self.content)
|
|
313
|
+
|
|
314
|
+
def _remove(self, key: str) -> None:
|
|
315
|
+
e = self.entries.get(key)
|
|
316
|
+
if e is None:
|
|
317
|
+
return
|
|
318
|
+
if e is self.root:
|
|
319
|
+
self._reset()
|
|
320
|
+
return
|
|
321
|
+
if e.parent is not None and e in e.parent.children:
|
|
322
|
+
e.parent.children.remove(e)
|
|
323
|
+
self._discard(e)
|
|
324
|
+
|
|
325
|
+
def _replace(self, a: Replace) -> None:
|
|
326
|
+
old = self.entries.get(a.key)
|
|
327
|
+
if old is None:
|
|
328
|
+
return
|
|
329
|
+
if old is self.root:
|
|
330
|
+
self._set_root(a.node)
|
|
331
|
+
return
|
|
332
|
+
parent = old.parent
|
|
333
|
+
index = parent.children.index(old)
|
|
334
|
+
self._discard(old)
|
|
335
|
+
parent.children[index] = self._build(a.node, parent)
|
|
336
|
+
|
|
337
|
+
def _discard(self, e: Entry) -> None:
|
|
338
|
+
self.entries.pop(e.node.key, None)
|
|
339
|
+
for c in e.children:
|
|
340
|
+
self._discard(c)
|
|
341
|
+
|
|
342
|
+
def _build(self, node: Native, parent: Entry | None) -> Entry:
|
|
343
|
+
e = Entry(self, node, parent)
|
|
344
|
+
if isinstance(node, (NativeBox, NativeList, NativeItem)):
|
|
345
|
+
e.widget = widgets.NLBoxWidget(e)
|
|
346
|
+
elif isinstance(node, (NativeText, NativeButton)):
|
|
347
|
+
e.widget = widgets.NLTextWidget(e)
|
|
348
|
+
elif isinstance(node, NativeField):
|
|
349
|
+
e.widget = widgets.NLFieldWidget(e)
|
|
350
|
+
e.widget.text = node.value
|
|
351
|
+
elif isinstance(node, NativeCheckbox):
|
|
352
|
+
e.widget = widgets.NLCheckWidget(e)
|
|
353
|
+
e.widget.active = node.checked
|
|
354
|
+
else:
|
|
355
|
+
e.widget = widgets.NLImageWidget(e)
|
|
356
|
+
self.entries[node.key] = e
|
|
357
|
+
self._configure(e)
|
|
358
|
+
for c in native_children(node):
|
|
359
|
+
e.children.append(self._build(c, e))
|
|
360
|
+
return e
|
|
361
|
+
|
|
362
|
+
def _configure(self, e: Entry) -> None:
|
|
363
|
+
n = e.node
|
|
364
|
+
w = e.widget
|
|
365
|
+
if isinstance(n, NativeField):
|
|
366
|
+
w.hint_text = n.hint
|
|
367
|
+
w.multiline = n.type == FieldType.AREA
|
|
368
|
+
w.password = n.type == FieldType.PASSWORD
|
|
369
|
+
w.input_filter = "float" if n.type == FieldType.NUMBER else None
|
|
370
|
+
w.readonly = n.read_only
|
|
371
|
+
elif isinstance(n, NativeImage):
|
|
372
|
+
e.texture = self._load_texture(n.source) if e.icon_name() is None else None
|
|
373
|
+
|
|
374
|
+
def _load_texture(self, source: str):
|
|
375
|
+
try:
|
|
376
|
+
if source.startswith("data:"):
|
|
377
|
+
head, _, payload = source.partition(",")
|
|
378
|
+
ext = head[5:].split(";")[0].split("/")[-1] or "png"
|
|
379
|
+
return CoreImage(io.BytesIO(base64.b64decode(payload)), ext=ext).texture
|
|
380
|
+
r = self.image_resolver(source)
|
|
381
|
+
return getattr(r, "texture", r)
|
|
382
|
+
except Exception:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
# ---------------------------------------------------------------- layout
|
|
386
|
+
def schedule_layout(self) -> None:
|
|
387
|
+
if not self._scheduled:
|
|
388
|
+
self._scheduled = True
|
|
389
|
+
Clock.schedule_once(self._run_layout, 0)
|
|
390
|
+
|
|
391
|
+
def redraw_entry(self, e: Entry) -> None:
|
|
392
|
+
if e.widget is not None:
|
|
393
|
+
e.widget.redraw(self.px)
|
|
394
|
+
|
|
395
|
+
def _flow_lnodes(self, e: Entry, m: Metrics) -> list[LNode]:
|
|
396
|
+
"""Layout nodes of the children in the flow. Absolute children get their own node but stay out of the list."""
|
|
397
|
+
out: list[LNode] = []
|
|
398
|
+
for c in e.children:
|
|
399
|
+
if c.is_special:
|
|
400
|
+
continue
|
|
401
|
+
ln = self._lnode(c, m)
|
|
402
|
+
if not c.is_absolute:
|
|
403
|
+
out.append(ln)
|
|
404
|
+
return out
|
|
405
|
+
|
|
406
|
+
def background_texture(self, e: Entry):
|
|
407
|
+
"""Texture of `background-image` for an entry, or None (not set, or the image is not found)."""
|
|
408
|
+
name = e.style.background_image
|
|
409
|
+
if not name:
|
|
410
|
+
return None
|
|
411
|
+
if name not in self._bg_cache:
|
|
412
|
+
self._bg_cache[name] = self._load_texture(name)
|
|
413
|
+
return self._bg_cache[name]
|
|
414
|
+
|
|
415
|
+
def _insert(self, parent: Entry, child: Entry, position: int) -> None:
|
|
416
|
+
"""Inserts a created child. `position` counts only the siblings of the same kind (absolute or in the flow)."""
|
|
417
|
+
kind = child.is_absolute
|
|
418
|
+
seen = 0
|
|
419
|
+
for i, c in enumerate(parent.children):
|
|
420
|
+
if c.is_absolute == kind:
|
|
421
|
+
if seen == position:
|
|
422
|
+
parent.children.insert(i, child)
|
|
423
|
+
return
|
|
424
|
+
seen += 1
|
|
425
|
+
parent.children.append(child)
|
|
426
|
+
|
|
427
|
+
def _lnode(self, e: Entry, m: Metrics) -> LNode:
|
|
428
|
+
n = e.node
|
|
429
|
+
px = m.px
|
|
430
|
+
st = e.style
|
|
431
|
+
if isinstance(n, (NativeText, NativeButton)):
|
|
432
|
+
l = LNode(st, leaf=lambda mw, e=e: widgets.measure_text(e.markup(), e.style, px, mw), key=n.key)
|
|
433
|
+
elif isinstance(n, NativeField):
|
|
434
|
+
def meas(mw, e=e):
|
|
435
|
+
size = px(e.style.font_size)
|
|
436
|
+
tw, _ = widgets.measure_text(widgets.escape_markup(n.hint or " "), e.style, px, None)
|
|
437
|
+
return max(tw, dp(64)), size * 1.3 * max(n.rows, 1)
|
|
438
|
+
l = LNode(st, leaf=meas, key=n.key)
|
|
439
|
+
elif isinstance(n, NativeCheckbox):
|
|
440
|
+
l = LNode(st, leaf=lambda mw: (dp(32), dp(32)), key=n.key)
|
|
441
|
+
elif isinstance(n, NativeImage):
|
|
442
|
+
def meas(mw, e=e):
|
|
443
|
+
if e.icon_name() is not None:
|
|
444
|
+
s = IconSet.GRID * m.density
|
|
445
|
+
return s, s
|
|
446
|
+
if e.texture is None:
|
|
447
|
+
return 0.0, 0.0
|
|
448
|
+
w, h = e.texture.size
|
|
449
|
+
if mw is not None and w > mw:
|
|
450
|
+
h, w = h * mw / w, mw
|
|
451
|
+
return float(w), float(h)
|
|
452
|
+
l = LNode(st, leaf=meas, key=n.key)
|
|
453
|
+
elif isinstance(n, NativeItem):
|
|
454
|
+
marker = LNode(NativeStyle(), leaf=lambda mw, e=e: widgets.measure_text(
|
|
455
|
+
widgets.escape_markup(n.marker + " "), e.style, px, None))
|
|
456
|
+
content_style = NativeStyle(direction=st.direction, justify=st.justify, align_items=st.align_items,
|
|
457
|
+
gap=st.gap, flex=1.0)
|
|
458
|
+
inner = LNode(content_style, self._flow_lnodes(e, m))
|
|
459
|
+
row_style = replace(st, direction=Direction.ROW, justify=Justify.START, align_items=Align.START,
|
|
460
|
+
gap=Length.ZERO, wrap=False)
|
|
461
|
+
l = LNode(row_style, [marker, inner], key=n.key)
|
|
462
|
+
e.lnode, e.inner, e.marker_box = l, inner, marker
|
|
463
|
+
return l
|
|
464
|
+
else:
|
|
465
|
+
l = LNode(st, self._flow_lnodes(e, m), key=n.key)
|
|
466
|
+
e.lnode = l
|
|
467
|
+
e.inner = l
|
|
468
|
+
e.marker_box = None
|
|
469
|
+
return l
|
|
470
|
+
|
|
471
|
+
def _place(self, e: Entry, ox: float, oy: float, region_h: float, regions: list, px) -> None:
|
|
472
|
+
"""Sets the widgets (Kivy coordinates, origin at the bottom) from the positions of the engine."""
|
|
473
|
+
l = e.lnode
|
|
474
|
+
x, y = ox + l.x, oy + l.y
|
|
475
|
+
w = e.widget
|
|
476
|
+
w.size = (max(l.w, 0.0), max(l.h, 0.0))
|
|
477
|
+
w.pos = (x, region_h - y - l.h)
|
|
478
|
+
if isinstance(e.node, NativeItem):
|
|
479
|
+
m = e.marker_box
|
|
480
|
+
e.marker_box = (m.x, m.y, m.w, m.h)
|
|
481
|
+
regions.append(e)
|
|
482
|
+
inner = e.inner
|
|
483
|
+
cx, cy = x, y
|
|
484
|
+
if inner is not None and inner is not l:
|
|
485
|
+
cx, cy = x + inner.x, y + inner.y
|
|
486
|
+
# absolute children: below the flow when z-index is negative, above it otherwise
|
|
487
|
+
absolutes = sorted((c for c in e.children if c.is_absolute), key=lambda c: c.style.z_index)
|
|
488
|
+
metrics = self.metrics
|
|
489
|
+
for c in absolutes:
|
|
490
|
+
layout_absolute(c.lnode, metrics, l.w, l.h)
|
|
491
|
+
for c in absolutes:
|
|
492
|
+
if c.style.z_index < 0:
|
|
493
|
+
self._place(c, x, y, region_h, regions, px)
|
|
494
|
+
for c in e.children:
|
|
495
|
+
if not c.is_special and not c.is_absolute:
|
|
496
|
+
self._place(c, cx, cy, region_h, regions, px)
|
|
497
|
+
for c in absolutes:
|
|
498
|
+
if c.style.z_index >= 0:
|
|
499
|
+
self._place(c, x, y, region_h, regions, px)
|
|
500
|
+
|
|
501
|
+
def _run_layout(self, *_):
|
|
502
|
+
self._scheduled = False
|
|
503
|
+
root = self.root
|
|
504
|
+
W, H = self.area.size
|
|
505
|
+
if root is None or W <= 0 or H <= 0:
|
|
506
|
+
return
|
|
507
|
+
m = self.metrics
|
|
508
|
+
px = m.px
|
|
509
|
+
|
|
510
|
+
def sub(entry):
|
|
511
|
+
out = [entry]
|
|
512
|
+
for c in entry.children:
|
|
513
|
+
out += sub(c)
|
|
514
|
+
return out
|
|
515
|
+
|
|
516
|
+
# reset the markers of the items before they are computed again
|
|
517
|
+
header = next((c for c in root.children if c.is_header), None)
|
|
518
|
+
footers = [c for c in root.children if c.is_footer]
|
|
519
|
+
layers = [c for c in root.children if c.is_layer]
|
|
520
|
+
|
|
521
|
+
self._lnode(root, m)
|
|
522
|
+
for c in header and [header] or []:
|
|
523
|
+
self._lnode(c, m)
|
|
524
|
+
for c in footers + layers:
|
|
525
|
+
self._lnode(c, m)
|
|
526
|
+
|
|
527
|
+
# header: fixed above the scroll
|
|
528
|
+
header_h = 0.0
|
|
529
|
+
header_regions: list[Entry] = []
|
|
530
|
+
if header is not None:
|
|
531
|
+
hs = header.style
|
|
532
|
+
ml, mr, mt, mb = (px(hs.margin.left), px(hs.margin.right), px(hs.margin.top), px(hs.margin.bottom))
|
|
533
|
+
fw = px(hs.width.length) if isinstance(hs.width, Dim) else W - ml - mr
|
|
534
|
+
lay_node(header.lnode, m, fw, float("inf"), fw, px(hs.height.length) if isinstance(hs.height, Dim) else None)
|
|
535
|
+
header.lnode.x, header.lnode.y = ml, mt
|
|
536
|
+
header_h = header.lnode.h + mt + mb
|
|
537
|
+
self._place(header, 0, 0, header_h, header_regions, px)
|
|
538
|
+
|
|
539
|
+
# footers with dock bottom: under the scroll
|
|
540
|
+
bottom_footers = [f for f in footers if (f.style.dock or Dock.BOTTOM) == Dock.BOTTOM]
|
|
541
|
+
float_footers = [f for f in footers if f not in bottom_footers]
|
|
542
|
+
footer_h = 0.0
|
|
543
|
+
for f in bottom_footers:
|
|
544
|
+
fs = f.style
|
|
545
|
+
ml, mr, mt, mb = (px(fs.margin.left), px(fs.margin.right), px(fs.margin.top), px(fs.margin.bottom))
|
|
546
|
+
fw = px(fs.width.length) if isinstance(fs.width, Dim) else W - ml - mr
|
|
547
|
+
lay_node(f.lnode, m, fw, float("inf"), fw, px(fs.height.length) if isinstance(fs.height, Dim) else None)
|
|
548
|
+
f.lnode.x, f.lnode.y = ml, mt
|
|
549
|
+
footer_h = max(footer_h, f.lnode.h + mt + mb)
|
|
550
|
+
footer_regions: list[Entry] = []
|
|
551
|
+
for f in bottom_footers:
|
|
552
|
+
self._place(f, 0, 0, footer_h, footer_regions, px)
|
|
553
|
+
|
|
554
|
+
# body
|
|
555
|
+
viewport_h = max(H - header_h - footer_h, 0.0)
|
|
556
|
+
layout(root.lnode, m, W, viewport_h)
|
|
557
|
+
content_h = max(root.lnode.y + root.lnode.h + px(root.style.margin.bottom), viewport_h)
|
|
558
|
+
body_regions: list[Entry] = []
|
|
559
|
+
self._place(root, 0, 0, content_h, body_regions, px)
|
|
560
|
+
|
|
561
|
+
# floating footers and layers (dialogs)
|
|
562
|
+
float_regions: list[Entry] = []
|
|
563
|
+
for f in float_footers:
|
|
564
|
+
self._layout_floating(f, W, H, m, float_regions, px)
|
|
565
|
+
overlay_regions: list[Entry] = []
|
|
566
|
+
for lyr in layers:
|
|
567
|
+
lay_node(lyr.lnode, m, W, H, W, H)
|
|
568
|
+
lyr.lnode.x = lyr.lnode.y = 0.0
|
|
569
|
+
self._place(lyr, 0, 0, H, overlay_regions, px)
|
|
570
|
+
|
|
571
|
+
# regions
|
|
572
|
+
self.scroll.size = (W, viewport_h)
|
|
573
|
+
self.scroll.pos = (0, footer_h)
|
|
574
|
+
self.content.size = (W, content_h)
|
|
575
|
+
self.header_layer.size, self.header_layer.pos = (W, header_h), (0, H - header_h)
|
|
576
|
+
self.footer_layer.size, self.footer_layer.pos = (W, footer_h), (0, 0)
|
|
577
|
+
self.float_layer.size = self.overlay_layer.size = (W, H)
|
|
578
|
+
self.float_layer.pos = self.overlay_layer.pos = (0, 0)
|
|
579
|
+
|
|
580
|
+
if self._structure_dirty:
|
|
581
|
+
for layer, items in ((self.content, body_regions), (self.header_layer, header_regions),
|
|
582
|
+
(self.footer_layer, footer_regions), (self.float_layer, float_regions),
|
|
583
|
+
(self.overlay_layer, overlay_regions)):
|
|
584
|
+
layer.clear_widgets()
|
|
585
|
+
for e in items:
|
|
586
|
+
layer.add_widget(e.widget)
|
|
587
|
+
self._structure_dirty = False
|
|
588
|
+
for items in (body_regions, header_regions, footer_regions, float_regions, overlay_regions):
|
|
589
|
+
for e in items:
|
|
590
|
+
e.widget.redraw(px)
|
|
591
|
+
|
|
592
|
+
def _layout_floating(self, f: Entry, W: float, H: float, m: Metrics, regions: list, px) -> None:
|
|
593
|
+
fs = f.style
|
|
594
|
+
dock = fs.dock or Dock.BOTTOM
|
|
595
|
+
ml, mr, mt, mb = (px(fs.margin.left), px(fs.margin.right), px(fs.margin.top), px(fs.margin.bottom))
|
|
596
|
+
wide = dock in (Dock.BOTTOM, Dock.TOP)
|
|
597
|
+
fw = None
|
|
598
|
+
if isinstance(fs.width, Dim):
|
|
599
|
+
fw = px(fs.width.length)
|
|
600
|
+
elif (isinstance(fs.width, Percent) and fs.width.value >= 100) or wide:
|
|
601
|
+
fw = W - ml - mr
|
|
602
|
+
fh = None
|
|
603
|
+
if isinstance(fs.height, Dim):
|
|
604
|
+
fh = px(fs.height.length)
|
|
605
|
+
elif isinstance(fs.height, Percent) and fs.height.value >= 100:
|
|
606
|
+
fh = H - mt - mb
|
|
607
|
+
lay_node(f.lnode, m, W - ml - mr, H - mt - mb, fw, fh)
|
|
608
|
+
w, h = f.lnode.w, f.lnode.h
|
|
609
|
+
a = fs.align_self
|
|
610
|
+
if dock in (Dock.TOP, Dock.BOTTOM):
|
|
611
|
+
y = mt if dock == Dock.TOP else H - mb - h
|
|
612
|
+
x = (W - w) / 2 if a == Align.CENTER else (W - mr - w) if a == Align.END else ml
|
|
613
|
+
elif dock in (Dock.LEFT, Dock.RIGHT):
|
|
614
|
+
x = ml if dock == Dock.LEFT else W - mr - w
|
|
615
|
+
y = mt if a == Align.START else (H - mb - h) if a == Align.END else (H - h) / 2
|
|
616
|
+
else:
|
|
617
|
+
x, y = (W - w) / 2, (H - h) / 2
|
|
618
|
+
f.lnode.x, f.lnode.y = x, y
|
|
619
|
+
self._place(f, 0, 0, H, regions, px)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""NativeView for Kivy (Android and desktop): the widget of the screen and the program that drives it."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from nativelayout.core.program import NativeViewBase
|
|
8
|
+
|
|
9
|
+
from .kivy_host import KivyLayoutHost, NLArea
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def system_is_dark() -> bool:
|
|
13
|
+
"""Dark theme of the system (Android only; on the desktop it returns False)."""
|
|
14
|
+
try:
|
|
15
|
+
from jnius import autoclass
|
|
16
|
+
activity = autoclass("org.kivy.android.PythonActivity").mActivity
|
|
17
|
+
mode = activity.getResources().getConfiguration().uiMode
|
|
18
|
+
return (mode & 0x30) == 0x20
|
|
19
|
+
except Exception:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def file_loader(base_dir: str):
|
|
24
|
+
"""`loader(name) -> text | None` that reads files of a folder (without leaving it)."""
|
|
25
|
+
base = os.path.abspath(base_dir)
|
|
26
|
+
|
|
27
|
+
def load(name: str) -> str | None:
|
|
28
|
+
path = os.path.abspath(os.path.join(base, name))
|
|
29
|
+
if not path.startswith(base + os.sep) or not os.path.isfile(path):
|
|
30
|
+
return None
|
|
31
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
32
|
+
return f.read()
|
|
33
|
+
return load
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NativeView(NativeViewBase):
|
|
37
|
+
"""`NativeView(loader, app=first_screen)` or `NativeView(loader, body=async_def_body)`.
|
|
38
|
+
|
|
39
|
+
`ui.widget` is the Kivy widget to put in the window. `ui.start()` starts the program (it needs the
|
|
40
|
+
asyncio loop of Kivy: `App.async_run`). The Android back button calls `press_back()`.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, loader, app=None, body=None, dark=None, image_resolver=None, store_path=None):
|
|
44
|
+
self.widget = NLArea()
|
|
45
|
+
host = KivyLayoutHost(self.widget, image_resolver, store_path)
|
|
46
|
+
super().__init__(host, loader, app=app, body=body,
|
|
47
|
+
is_dark=(dark if callable(dark) else (lambda: bool(dark))) if dark is not None else system_is_dark)
|
|
48
|
+
self.on_exit = self._default_exit
|
|
49
|
+
from kivy.core.window import Window
|
|
50
|
+
Window.bind(on_keyboard=self._on_keyboard)
|
|
51
|
+
self._task: asyncio.Future | None = None
|
|
52
|
+
|
|
53
|
+
def start(self) -> None:
|
|
54
|
+
self._task = asyncio.ensure_future(self.run())
|
|
55
|
+
|
|
56
|
+
def _on_keyboard(self, _window, key, *_args) -> bool:
|
|
57
|
+
if key == 27: # Android back button (Esc on the desktop)
|
|
58
|
+
self.press_back()
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _default_exit() -> None:
|
|
64
|
+
from kivy.app import App
|
|
65
|
+
app = App.get_running_app()
|
|
66
|
+
if app is not None:
|
|
67
|
+
app.stop()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_app(loader, app=None, body=None, title: str = "nativelayout", **options) -> None:
|
|
71
|
+
"""Opens a Kivy window with the program. A shortcut for scripts and examples."""
|
|
72
|
+
from kivy.app import App
|
|
73
|
+
|
|
74
|
+
class _App(App):
|
|
75
|
+
def build(self):
|
|
76
|
+
self.title = title
|
|
77
|
+
self.ui = NativeView(loader, app=app, body=body, **options)
|
|
78
|
+
return self.ui.widget
|
|
79
|
+
|
|
80
|
+
def on_start(self):
|
|
81
|
+
self.ui.start()
|
|
82
|
+
|
|
83
|
+
def on_stop(self):
|
|
84
|
+
self.ui.close()
|
|
85
|
+
|
|
86
|
+
asyncio.run(_App().async_run(async_lib="asyncio"))
|