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,334 @@
1
+ """Kivy widgets of the host. Each draws its own background; the position comes from the flex engine."""
2
+ from __future__ import annotations
3
+
4
+ from kivy.core.text.markup import MarkupLabel as CoreLabel
5
+ from kivy.graphics import Color, Line, Rectangle, InstructionGroup
6
+ from kivy.uix.relativelayout import RelativeLayout
7
+ from kivy.uix.textinput import TextInput
8
+ from kivy.uix.widget import Widget
9
+ from kivy.utils import escape_markup
10
+
11
+ from nativelayout.core.tree import (Align, Direction, FontFamily, Justify, LineHeightFixed,
12
+ LineHeightMultiple, NativeStyle, Span, TextAlign)
13
+ from nativelayout.core.tree import ImagePos
14
+
15
+ from . import draw
16
+
17
+ TAP_SLOP = 12.0 # pixels of movement after which a touch is no longer a click
18
+
19
+
20
+ def _font_name(f: FontFamily) -> str | None:
21
+ if f == FontFamily.MONOSPACE:
22
+ from kivy.resources import resource_find
23
+ return resource_find("data/fonts/RobotoMono-Regular.ttf") or None
24
+ return None
25
+
26
+
27
+ # ---------------------------------------------------------------- text
28
+ def markup_of(spans: list[Span], px) -> str:
29
+ out = []
30
+ for s in spans:
31
+ t = escape_markup(s.text)
32
+ if not t:
33
+ continue
34
+ c = s.color
35
+ hexcol = "%02x%02x%02x%02x" % ((c >> 16) & 255, (c >> 8) & 255, c & 255, (c >> 24) & 255)
36
+ t = f"[size={max(1, int(round(px(s.size))))}]{t}[/size]"
37
+ if s.bold:
38
+ t = f"[b]{t}[/b]"
39
+ if s.italic:
40
+ t = f"[i]{t}[/i]"
41
+ if s.underline:
42
+ t = f"[u]{t}[/u]"
43
+ if s.strike:
44
+ t = f"[s]{t}[/s]"
45
+ out.append(f"[color={hexcol}]{t}[/color]")
46
+ return "".join(out)
47
+
48
+
49
+ _measure_cache: dict = {}
50
+
51
+
52
+ def make_label(markup: str, style: NativeStyle, px, max_w: float | None, halign: str = "left") -> CoreLabel:
53
+ size = max(1, int(round(px(style.font_size))))
54
+ kwargs = dict(text=markup, markup=True, font_size=size, halign=halign, bold=style.bold,
55
+ italic=style.italic, text_size=(max_w, None))
56
+ fn = _font_name(style.font_family)
57
+ if fn:
58
+ kwargs["font_name"] = fn
59
+ lh = style.line_height
60
+ if isinstance(lh, LineHeightMultiple):
61
+ kwargs["line_height"] = lh.value
62
+ elif isinstance(lh, LineHeightFixed):
63
+ kwargs["line_height"] = px(lh.length) / max(size, 1)
64
+ label = CoreLabel(**kwargs)
65
+ label.refresh()
66
+ return label
67
+
68
+
69
+ def measure_text(markup: str, style: NativeStyle, px, max_w: float | None) -> tuple[float, float]:
70
+ """Natural size of the text; if it does not fit in `max_w`, it wraps and returns the widest line."""
71
+ if not markup:
72
+ return 0.0, px(style.font_size) * 1.2
73
+ key = (markup, style.font_size, style.bold, style.italic, style.font_family, style.line_height,
74
+ None if max_w is None else round(max_w, 1), px(style.font_size))
75
+ hit = _measure_cache.get(key)
76
+ if hit is not None:
77
+ return hit
78
+ label = make_label(markup, style, px, None)
79
+ w, h = label.content_size if label.texture else (0, 0)
80
+ if max_w is not None and w > max_w:
81
+ label = make_label(markup, style, px, max_w)
82
+ lines = getattr(label, "_cached_lines", None)
83
+ w = max((ln.w for ln in lines), default=max_w) if lines else max_w
84
+ h = label.content_size[1] if label.texture else h
85
+ if len(_measure_cache) > 2000:
86
+ _measure_cache.clear()
87
+ _measure_cache[key] = (float(w), float(h))
88
+ return float(w), float(h)
89
+
90
+
91
+ def text_gravity(style: NativeStyle, is_button: bool) -> tuple[str, str]:
92
+ """(horizontal, vertical) = ('left'|'center'|'right', 'top'|'center'|'bottom'), like the Android host."""
93
+ if is_button:
94
+ h = {"CENTER": "center", "END": "right"}.get(style.text_align.name, "left")
95
+ return h, "center"
96
+ h = {"CENTER": "center", "END": "right"}.get(style.text_align.name, "left")
97
+ if style.direction == Direction.COLUMN:
98
+ if style.align_items != Align.STRETCH and style.text_align == TextAlign.START:
99
+ h = {"CENTER": "center", "END": "right"}.get(style.align_items.name, "left")
100
+ v = {"CENTER": "center", "END": "bottom"}.get(style.justify.name, "top")
101
+ else:
102
+ if style.justify != Justify.START:
103
+ h = {"CENTER": "center", "END": "right"}.get(style.justify.name, "left")
104
+ v = {"CENTER": "center", "END": "bottom"}.get(style.align_items.name, "top")
105
+ return h, v
106
+
107
+
108
+ # ---------------------------------------------------------------- base
109
+ class NLWidget(Widget):
110
+ """A widget whose background we draw. `entry` links it to the model of the host."""
111
+
112
+ def __init__(self, entry, **kw):
113
+ super().__init__(size_hint=(None, None), **kw)
114
+ self.entry = entry
115
+ self.pressed = False
116
+ self._touch_start = None
117
+ self._moved = False
118
+
119
+ # drawing: subclasses add on top of the background
120
+ def redraw(self, px) -> None:
121
+ e = self.entry
122
+ st = e.style
123
+ self.canvas.before.clear()
124
+ self.canvas.clear()
125
+ bg = border = None
126
+ states = st.states
127
+ if self.pressed and states and states.pressed:
128
+ bg = states.pressed.background_color
129
+ border = states.pressed.border_color
130
+ elif e.is_disabled() and states and states.disabled:
131
+ bg = states.disabled.background_color
132
+ border = states.disabled.border_color
133
+ elif getattr(self, "focused", False) and states and states.focused:
134
+ bg = states.focused.background_color
135
+ border = states.focused.border_color
136
+ draw.draw_box(self.canvas.before, self.x, self.y, self.width, self.height, st, px, bg, border,
137
+ image=e.host.background_texture(e))
138
+ self.draw_content(px)
139
+ self.opacity = e.effective_opacity()
140
+
141
+ def draw_content(self, px) -> None:
142
+ pass
143
+
144
+ # touches
145
+ def wants_touch(self) -> bool:
146
+ return self.entry.interactive() and not self.entry.is_disabled()
147
+
148
+ def on_touch_down(self, touch):
149
+ if not self.collide_point(*touch.pos):
150
+ return False
151
+ if self.entry.is_layer:
152
+ if self.wants_touch():
153
+ pass
154
+ self._touch_start = touch.pos
155
+ self._moved = False
156
+ touch.grab(self)
157
+ return True
158
+ if not self.wants_touch():
159
+ return False
160
+ self._touch_start = touch.pos
161
+ self._moved = False
162
+ self.pressed = True
163
+ touch.grab(self)
164
+ self.entry.host.redraw_entry(self.entry)
165
+ return True
166
+
167
+ def on_touch_move(self, touch):
168
+ if touch.grab_current is self and self._touch_start is not None:
169
+ dx = touch.pos[0] - self._touch_start[0]
170
+ dy = touch.pos[1] - self._touch_start[1]
171
+ if abs(dx) > TAP_SLOP or abs(dy) > TAP_SLOP:
172
+ self._moved = True
173
+ if self.pressed:
174
+ self.pressed = False
175
+ self.entry.host.redraw_entry(self.entry)
176
+ return False
177
+
178
+ def on_touch_up(self, touch):
179
+ if touch.grab_current is self:
180
+ touch.ungrab(self)
181
+ was = self.pressed
182
+ self.pressed = False
183
+ if was:
184
+ self.entry.host.redraw_entry(self.entry)
185
+ if was and not self._moved and self.collide_point(*touch.pos):
186
+ self.on_tap()
187
+ return True
188
+ return False
189
+
190
+ def on_tap(self) -> None:
191
+ self.entry.host.publish(self.entry.node.key)
192
+
193
+
194
+ class NLBoxWidget(NLWidget):
195
+ def draw_content(self, px) -> None:
196
+ e = self.entry
197
+ mb = e.marker_box
198
+ if mb is None:
199
+ return
200
+ mx, my, mw, mh = mb
201
+ label = make_label(escape_markup(e.node.marker + " "), e.style, px, None)
202
+ tex = label.texture
203
+ if tex is None:
204
+ return
205
+ self.canvas.add(Color(*draw.rgba(e.style.text_color)))
206
+ self.canvas.add(Rectangle(texture=tex, pos=(self.x + mx, self.y + self.height - my - tex.size[1]),
207
+ size=tex.size))
208
+
209
+
210
+ class NLTextWidget(NLWidget):
211
+ """Text (and buttons): draws the text inside the content area, with the gravity of the style."""
212
+
213
+ def draw_content(self, px) -> None:
214
+ e = self.entry
215
+ st = e.style
216
+ markup = e.markup()
217
+ if not markup:
218
+ return
219
+ pl, pr = px(st.padding.left), px(st.padding.right)
220
+ pt, pb = px(st.padding.top), px(st.padding.bottom)
221
+ cw = max(self.width - pl - pr, 1.0)
222
+ ch = max(self.height - pt - pb, 1.0)
223
+ hal, val = text_gravity(st, e.is_button)
224
+ label = make_label(markup, st, px, cw, hal)
225
+ tex = label.texture
226
+ if tex is None:
227
+ return
228
+ tw, th = tex.size
229
+ x = self.x + pl
230
+ if hal == "center":
231
+ x += (cw - tw) / 2
232
+ elif hal == "right":
233
+ x += cw - tw
234
+ if val == "center":
235
+ y = self.y + pb + (ch - th) / 2
236
+ elif val == "bottom":
237
+ y = self.y + pb
238
+ else:
239
+ y = self.y + pb + ch - th
240
+ self.canvas.add(Color(1, 1, 1, 1))
241
+ self.canvas.add(Rectangle(texture=tex, pos=(x, y), size=(tw, th)))
242
+
243
+
244
+ class NLCheckWidget(NLWidget):
245
+ def __init__(self, entry, **kw):
246
+ super().__init__(entry, **kw)
247
+ self.active = False
248
+
249
+ def draw_content(self, px) -> None:
250
+ e = self.entry
251
+ st = e.style
252
+ s = min(self.width, self.height)
253
+ x = self.x + (self.width - s) / 2
254
+ y = self.y + (self.height - s) / 2
255
+ box = s * 0.72
256
+ bx, by = x + (s - box) / 2, y + (s - box) / 2
257
+ if self.active:
258
+ self.canvas.add(Color(*draw.rgba(0xFF2F81F7)))
259
+ self.canvas.add(draw.RoundedRectangle(pos=(bx, by), size=(box, box), radius=[box * 0.15] * 4))
260
+ self.canvas.add(Color(1, 1, 1, 1))
261
+ self.canvas.add(Line(points=[bx + box * 0.2, by + box * 0.5, bx + box * 0.42, by + box * 0.28,
262
+ bx + box * 0.8, by + box * 0.74],
263
+ width=max(1.5, box * 0.12), cap="round", joint="round"))
264
+ else:
265
+ self.canvas.add(Color(*draw.rgba(0xFF767676)))
266
+ self.canvas.add(Line(rounded_rectangle=(bx, by, box, box, box * 0.15), width=max(1.5, box * 0.09)))
267
+
268
+ def on_tap(self) -> None:
269
+ self.active = not self.active
270
+ self.entry.host.redraw_entry(self.entry)
271
+ if self.entry.interactive():
272
+ self.entry.host.publish(self.entry.node.key)
273
+
274
+ def wants_touch(self) -> bool:
275
+ return not self.entry.is_disabled()
276
+
277
+
278
+ class NLImageWidget(NLWidget):
279
+ def draw_content(self, px) -> None:
280
+ e = self.entry
281
+ st = e.style
282
+ icon = e.icon_name()
283
+ if icon is not None:
284
+ s = min(self.width, self.height)
285
+ draw.draw_icon(self.canvas, icon, self.x + (self.width - s) / 2, self.y + (self.height - s) / 2,
286
+ s, st.text_color)
287
+ return
288
+ tex = e.texture
289
+ if tex is None:
290
+ self.canvas.add(Color(*draw.rgba(0xFFDDDDDD)))
291
+ self.canvas.add(Rectangle(pos=self.pos, size=self.size))
292
+ return
293
+ # object-fit: contain (default), cover or fill; the image is always centered
294
+ draw.draw_image(self.canvas, tex, self.x, self.y, self.width, self.height, st.object_fit, ImagePos.CENTER)
295
+
296
+
297
+ class NLFieldWidget(TextInput):
298
+ """Text field: we draw the background and the border; the TextInput only shows the text."""
299
+
300
+ def __init__(self, entry, **kw):
301
+ super().__init__(size_hint=(None, None), background_normal="", background_active="",
302
+ background_disabled_normal="",
303
+ background_color=(0, 0, 0, 0), write_tab=False, **kw)
304
+ self.entry = entry
305
+ self.focused = False
306
+ self.bind(focus=self._on_focus)
307
+
308
+ def _on_focus(self, _w, value) -> None:
309
+ self.focused = bool(value)
310
+ self.entry.host.redraw_entry(self.entry)
311
+
312
+ def redraw(self, px) -> None:
313
+ e = self.entry
314
+ st = e.style
315
+ self.canvas.before.clear()
316
+ bg = border = None
317
+ states = st.states
318
+ if e.is_disabled() and states and states.disabled:
319
+ bg, border = states.disabled.background_color, states.disabled.border_color
320
+ elif self.focused and states and states.focused:
321
+ bg, border = states.focused.background_color, states.focused.border_color
322
+ draw.draw_box(self.canvas.before, self.x, self.y, self.width, self.height, st, px, bg, border,
323
+ image=e.host.background_texture(e))
324
+ pad = [px(st.padding.left), px(st.padding.top), px(st.padding.right), px(st.padding.bottom)]
325
+ self.padding = pad
326
+ self.font_size = max(1, int(round(px(st.font_size))))
327
+ self.foreground_color = draw.rgba(st.text_color)
328
+ self.cursor_color = draw.rgba(st.text_color)
329
+ self.hint_text_color = draw.rgba((st.text_color & 0x00FFFFFF) | 0x80000000)
330
+ self.opacity = e.effective_opacity()
331
+ self.disabled = e.is_disabled()
332
+ fn = _font_name(st.font_family)
333
+ if fn:
334
+ self.font_name = fn
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: nativelayout
3
+ Version: 1.2.0
4
+ Summary: Create native interfaces from HTML, in any language. Python core and Kivy host.
5
+ Author: Adilson C. Rafael
6
+ License-Expression: MPL-2.0
7
+ Project-URL: Homepage, https://getnativelayout.github.io/nativelayout/
8
+ Project-URL: Source, https://github.com/getnativelayout/nativelayout
9
+ Project-URL: Issues, https://github.com/getnativelayout/nativelayout/issues
10
+ Project-URL: Specification, https://github.com/getnativelayout/nativelayout/blob/main/SPEC.md
11
+ Keywords: ui,layout,html,css,native,kivy,android
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: User Interfaces
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: kivy
24
+ Requires-Dist: kivy>=2.0; extra == "kivy"
25
+ Dynamic: license-file
26
+
27
+ # nativelayout (Python)
28
+
29
+ Python port of the nativelayout core. It follows the Kotlin core feature for feature
30
+ (currently v1.2.0: `<include>`, identity inside `for`, `<footer>` and `dock`,
31
+ `position: absolute`, `background-image`, `object-fit`).
32
+
33
+ - `core/` pure Python, no Kivy: parser, expander, layout engine, differ, program side
34
+ - `android/` Kivy host for Android (`nativelayout/host`)
35
+ - `desktop/` Kivy host for the desktop (`nativelayout/host`)
36
+ - `examples/` examples (`contador/`)
37
+
38
+ The Kivy hosts draw on a canvas; they do not build platform-native views.
39
+
40
+ ## Install
41
+
42
+ ```
43
+ pip install nativelayout # core only (pure Python, no dependencies)
44
+ pip install "nativelayout[kivy]" # core + Kivy host
45
+ ```
46
+
47
+ Requires Python 3.10 or newer. On Android, package with buildozer (`requirements = python3,kivy,nativelayout`).
48
+
49
+ `nativelayout` is a namespace package (no `__init__.py` at its root): the core is `nativelayout.core` and the Kivy host is `nativelayout.host`. The package published on PyPI carries the Android host (`android/`), which includes the stencil mask that rounds `background-image` corners. To work from the repository instead, put `core` and the folder of the host on `sys.path`.
50
+
51
+ ## Usage
52
+
53
+ ```python
54
+ import kivy # noqa: F401 (Pydroid starts the Kivy window only if the opened file imports kivy)
55
+ from nativelayout.core.program import state, view, click
56
+ from nativelayout.host import run_app, file_loader
57
+
58
+ def screen():
59
+ s = state({"n": 0})
60
+ if click(".more"):
61
+ s["n"] += 1
62
+ return view("screen.nl", s)
63
+
64
+ run_app(file_loader("."), app=screen)
65
+ ```
66
+
67
+ The host needs Kivy 2.x.
68
+
69
+ ## Tests
70
+
71
+ ```
72
+ cd python/core
73
+ python -m unittest discover -s tests -t .
74
+
75
+ cd python/android
76
+ python -m unittest discover -s tests -t .
77
+ ```
78
+
79
+ The core tests mirror the Kotlin ones (`IncludeTest`, `FooterTest`, `AbsoluteTest`). The host tests
80
+ cover `flex.py`, which does not need Kivy.
81
+
82
+ ## Status of the host (v1.2.0)
83
+
84
+ Done: columns and rows, `flex`, `gap`, `justify`, `align`, wrap, header, footer and `dock`, dialogs,
85
+ `position: absolute` (with `z-index`), `background-image` (`background-size`, `background-position`),
86
+ `object-fit`, icons, gradients, shadows, rounded corners.
87
+
88
+ Not done yet:
89
+ - `virtual` lists draw all items (no recycling)
90
+ - animations: only the fade-in of the screen (slide-up and slide-left are missing)
91
+ - `letter-spacing` and `overflow: hidden` (clipping) are not applied
92
+ - the Android keyboard can cover fields (adjust `softinput_mode`)
93
+ - the Kivy drawing of `background-image` and `object-fit` has not been tried on a device yet
@@ -0,0 +1,23 @@
1
+ nativelayout/core/__init__.py,sha256=xpPrQJud9pdJlfRBK0vtVHdHljWZb0h9-fjB5h3CAD0,812
2
+ nativelayout/core/css.py,sha256=xjMq7L3mF0Eo9FK8xQzY1tdehhdS-IER62kIZUKgNeU,6550
3
+ nativelayout/core/differ.py,sha256=9YLMgvde1yhkp6IC_NGdWDJ61ca4oNiPLVNxuMcLSlY,4353
4
+ nativelayout/core/errors.py,sha256=USVPk1OZXgOHHWlkd1jwh_tCiH1zoqyFcEdCmPj-sP8,841
5
+ nativelayout/core/expander.py,sha256=6bzHcf9xdRWCSHQqrLR_K7TtxrK54fjltpyRwLaoZYo,50088
6
+ nativelayout/core/html_parser.py,sha256=Y4SZUrygqWF7yZ7TrGRwcy2mOGsZz3dE4X5p3BuPz-I,11122
7
+ nativelayout/core/icons.py,sha256=irZeRjXkISW6uLRxOVXyum3FtmonmmLnVjlVZPyeDfg,3396
8
+ nativelayout/core/layout_engine.py,sha256=8OiR3YSiLn2rXEqpOdtNfsrklF21VJ60wp2P3B8PDws,44621
9
+ nativelayout/core/layout_host.py,sha256=yi3XLHkXNo2b1h0fEbRnM4N-AccQxhWG-b5gj_Nkt3E,8481
10
+ nativelayout/core/program.py,sha256=B-BvyQVUoSY6Kk13wf0uoK2AUxgEMjoXSrgE5z-J8Yg,10286
11
+ nativelayout/core/scopes.py,sha256=jKFn-vUT8VfKNAjT3I7exUy88K5JYqtg6Qn2n3NsEEg,1592
12
+ nativelayout/core/tree.py,sha256=uJEghKKQgS7-yAD4ZU3XeGJ-qop-kG1VZ1HZqrIH35A,9811
13
+ nativelayout/host/__init__.py,sha256=raatzN60CQboJNdy2F-5biQ9Z5ae28huMcSpyoQ16yw,211
14
+ nativelayout/host/draw.py,sha256=HthL22HER8EpPZoviV588xx3uIZyigAJj7edHSKzmgc,9132
15
+ nativelayout/host/flex.py,sha256=5cMDWGAcRvkS8ovF4Ktfih7SSIySTqd1wQeegSAItfc,14597
16
+ nativelayout/host/kivy_host.py,sha256=8Q_U8zHo7kPP1DfBHF_1UFRpIpzUihKAWi9-NS1Igd4,25045
17
+ nativelayout/host/native_view.py,sha256=T0bgw-oGfRxap2JJDd-yjvY3Tc4fNzWoriBMjhOKoEo,3033
18
+ nativelayout/host/widgets.py,sha256=V6-AcDM24QGUla52eqiV-L1lDcT5nP7Cw4NMWYKJaow,13114
19
+ nativelayout-1.2.0.dist-info/licenses/LICENSE,sha256=ZqMQfVrWoFiqt1PqrCBHzLLtDjlGXdD-WETaPjANUXI,16727
20
+ nativelayout-1.2.0.dist-info/METADATA,sha256=ay1xlcjwpnsBNsPakmWQ7jUrSMgi1qcoG90lCC6_2c8,3700
21
+ nativelayout-1.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
22
+ nativelayout-1.2.0.dist-info/top_level.txt,sha256=Nwt6HOc6nsE7dnYAJCmq4uiKWLGT-9Dyur_8iCGDh5g,13
23
+ nativelayout-1.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+