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,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"))