zkite-framework 0.1.0rc1__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.
zkite/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ from .app import App
2
+ from ._store import store
3
+ from ._anim import Anim, AnimEnd
4
+ from ._system_bars import SystemBars
5
+ from ._nav import Entry, Nav, NavigationHost, Navigator
6
+ from .widgets import (
7
+ Button,
8
+ Checkbox,
9
+ Column,
10
+ Grid,
11
+ Icon,
12
+ Image,
13
+ List,
14
+ Overlay,
15
+ Radio,
16
+ Row,
17
+ ScrollView,
18
+ Select,
19
+ Slider,
20
+ Stack,
21
+ Switch,
22
+ Text,
23
+ TextField,
24
+ Widget,
25
+ )
26
+
27
+ __all__ = [
28
+ "App",
29
+ "SystemBars",
30
+ "store",
31
+ "Anim",
32
+ "AnimEnd",
33
+ "Widget",
34
+ "Column",
35
+ "Row",
36
+ "Grid",
37
+ "Stack",
38
+ "ScrollView",
39
+ "Text",
40
+ "Button",
41
+ "Icon",
42
+ "TextField",
43
+ "Image",
44
+ "Switch",
45
+ "Checkbox",
46
+ "Radio",
47
+ "Slider",
48
+ "List",
49
+ "Overlay",
50
+ "Select",
51
+ "Nav",
52
+ "Navigator",
53
+ "NavigationHost",
54
+ "Entry",
55
+ ]
zkite/_anim.py ADDED
@@ -0,0 +1,337 @@
1
+ """The author-facing animation surface — phase_3/PLAN.md §47, Queue F layer 3.
2
+
3
+ An animation is **declared on a widget**, not commanded:
4
+
5
+ Column("flex-1 items-center justify-center",
6
+ Text("text-2xl", "Hello"),
7
+ anim=Anim.enter(opacity=(0, 1), translate_y=(16, 0), ms=220),
8
+ )
9
+
10
+ The declaration crosses to the renderer as one `anim` attribute, is started by the
11
+ render thread's timeline, and is sampled per frame **with no Python in the loop**.
12
+ That last part is the constraint the whole design is built around, and it was priced
13
+ rather than assumed (phase_3/PLAN.md §45.2): a frame that re-runs `view()` costs
14
+ ~16.03ms of a 16.67ms budget, and `view()`'s cost scales with the *whole screen*, so
15
+ fading one 48dp icon from Python would cost what rebuilding a 25-row list costs.
16
+
17
+ # Why explicit channels, and not `transition-*` classes
18
+
19
+ The owner's decision (§45.3 principle 2). Tailwind's `transition-*` would have been
20
+ the smaller-looking API and would have bought CSS's semantics with it — its
21
+ interruption behaviour, its property-change triggers, its `transitionend` timing —
22
+ which is a new silent divergence between the two backends acquired for a feature meant
23
+ to remove one. §32.1 rejected HTML's `<label>` on exactly this argument, with a
24
+ measurement behind it.
25
+
26
+ # The two channels, and why one of them is not free
27
+
28
+ `opacity` and `translate_x`/`translate_y`. Measured on a Moto G54 (§46.6): one
29
+ `opacity` group spanning the full screen costs **1.88ms/frame**, 16% of the frame's
30
+ total work, because compositing a subtree at partial alpha needs an offscreen render
31
+ target. A translate opens nothing at all — it reuses the per-node offset channel the
32
+ renderer already had for scrolling.
33
+
34
+ So: a 200–300ms fade costs ~28ms spread over ~15 frames and is entirely affordable; a
35
+ *persistent* full-screen opacity group costs 1.9ms/frame forever against a frame with
36
+ ~5ms of slack. `translate_*` is the right default for anything long-lived, and
37
+ [`Anim`] warns when a declaration looks like the expensive case.
38
+
39
+ `rotate` and `scale` are **not** here. They parse in `twcore` and work on the WebView
40
+ backend, and the native paint path drops them (§45.7) — so animating one would run,
41
+ complete, fire its callback and never move a pixel. That is §15.1's silent-divergence
42
+ shape exactly, and refusing is the whole reason this is an explicit channel list.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ import sys
49
+ from typing import Any, Iterable
50
+
51
+ #: The channels the native renderer can actually paint. Kept in lockstep with
52
+ #: `zkite_renderer::Channel` — that enum is the authority and rejects anything else,
53
+ #: so this list exists to give the author a Python-level error at the point they
54
+ #: wrote the typo rather than a log line from the render thread.
55
+ CHANNELS = ("opacity", "translate_x", "translate_y")
56
+
57
+ #: Channels that are parsed by `twcore`, painted by the WebView backend, and dropped
58
+ #: by the native one (§45.7). Named separately so the error can say *why*.
59
+ UNPAINTED_CHANNELS = ("rotate", "scale", "scale_x", "scale_y")
60
+
61
+ _EASINGS = ("linear", "ease", "in", "out", "in_out")
62
+ _DIRECTIONS = ("normal", "reverse", "alternate", "alternate_reverse")
63
+ _TRIGGERS = ("enter", "change", "exit")
64
+
65
+ _warned_html_backend = False
66
+
67
+
68
+ class AnimEnd:
69
+ """The payload an `on_animend` handler receives.
70
+
71
+ A small object rather than a bare string because a handler almost always has to
72
+ branch on one of the two fields, and `payload == "exit"` versus
73
+ `payload["trigger"] == "exit"` is exactly the guessing game §26.2's string payload
74
+ caused three separate times.
75
+ """
76
+
77
+ __slots__ = ("channel", "trigger")
78
+
79
+ def __init__(self, channel: str, trigger: str) -> None:
80
+ self.channel = channel
81
+ self.trigger = trigger
82
+
83
+ def __repr__(self) -> str: # pragma: no cover - diagnostic only
84
+ return f"AnimEnd(channel={self.channel!r}, trigger={self.trigger!r})"
85
+
86
+ def __eq__(self, other: Any) -> bool:
87
+ return (
88
+ isinstance(other, AnimEnd)
89
+ and other.channel == self.channel
90
+ and other.trigger == self.trigger
91
+ )
92
+
93
+
94
+ def parse_anim_end(payload: Any) -> AnimEnd:
95
+ """Type an `animend` payload at the widget, the way `Switch`/`Slider` type theirs.
96
+
97
+ Every backend's event bridge is string-in, string-out (see
98
+ `RenderContext.register`), so without this a handler would receive the raw JSON
99
+ text `'{"channel":"opacity","trigger":"exit"}'`. §26.2 is the reason this is not
100
+ left to the caller: the same "the payload is a string" gap bit `Switch`, `Slider`
101
+ and `Select` in turn, and each time the fix was to type it here.
102
+ """
103
+ if isinstance(payload, AnimEnd):
104
+ return payload
105
+ try:
106
+ data = json.loads(payload) if isinstance(payload, str) else dict(payload or {})
107
+ except (ValueError, TypeError):
108
+ data = {}
109
+ return AnimEnd(str(data.get("channel", "")), str(data.get("trigger", "")))
110
+
111
+
112
+ class Anim:
113
+ """One trigger's worth of animation, over one or more channels.
114
+
115
+ Build one with [`enter`](Anim.enter), [`change`](Anim.change) or
116
+ [`exit_`](Anim.exit_) rather than calling this directly — the trigger is the thing
117
+ an author is choosing between, so it belongs in the name.
118
+
119
+ A channel is given either as a target (`opacity=1`) or as a `(from, to)` pair
120
+ (`opacity=(0, 1)`). An unstated `from` means **"wherever this node's class string
121
+ already puts it"**, resolved on the renderer side, because that value depends on
122
+ `twcore`, on style inheritance, and — for `1em`-relative sizes — on ancestry that
123
+ Python cannot see (§18.4). Defaulting it to `0` here would make every unstated
124
+ fade start from fully transparent.
125
+ """
126
+
127
+ __slots__ = ("trigger", "channels", "ms", "ease", "spring", "delay", "repeat", "dir")
128
+
129
+ def __init__(
130
+ self,
131
+ trigger: str,
132
+ *,
133
+ ms: float | None = None,
134
+ ease: str | tuple[float, float, float, float] | list[float] | None = None,
135
+ spring: tuple[float, float] | list[float] | None = None,
136
+ delay: float = 0.0,
137
+ repeat: int | str | None = None,
138
+ dir: str | None = None,
139
+ **channels: Any,
140
+ ) -> None:
141
+ if trigger not in _TRIGGERS:
142
+ raise ValueError(f"unknown animation trigger {trigger!r}; use one of {', '.join(_TRIGGERS)}")
143
+ if not channels:
144
+ raise ValueError(
145
+ f"Anim.{_ctor_name(trigger)}() declares no channels; "
146
+ f"pass at least one of {', '.join(CHANNELS)}"
147
+ )
148
+ for name in channels:
149
+ if name in UNPAINTED_CHANNELS:
150
+ raise ValueError(
151
+ f"{name!r} is parsed by twcore and painted by nothing on the native "
152
+ f"renderer (phase_3/PLAN.md §45.7), so animating it would run, finish "
153
+ f"and move no pixels. Animatable channels: {', '.join(CHANNELS)}"
154
+ )
155
+ if name not in CHANNELS:
156
+ raise ValueError(
157
+ f"unknown animation channel {name!r}; use one of {', '.join(CHANNELS)}"
158
+ )
159
+ # Both or neither is an error rather than a default, because an eased curve and
160
+ # a spring are genuinely different animations and picking one silently is how a
161
+ # framework acquires a surprising default nobody can find later.
162
+ if (ms is None) == (spring is None):
163
+ raise ValueError(
164
+ "an animation needs exactly one of ms=<duration> or spring=(stiffness, damping)"
165
+ )
166
+ if spring is not None and ease is not None:
167
+ raise ValueError("a spring has no easing — ease= and spring= are exclusive")
168
+ if isinstance(ease, str) and ease not in _EASINGS:
169
+ raise ValueError(f"unknown easing {ease!r}; use one of {', '.join(_EASINGS)}, or [x1,y1,x2,y2]")
170
+ if dir is not None and dir not in _DIRECTIONS:
171
+ raise ValueError(f"unknown dir {dir!r}; use one of {', '.join(_DIRECTIONS)}")
172
+ if isinstance(repeat, str) and repeat != "forever":
173
+ raise ValueError(f"unknown repeat {repeat!r}; use a count or \"forever\"")
174
+ if trigger == "exit" and repeat == "forever":
175
+ raise ValueError(
176
+ "an exit animation cannot repeat forever — its completion is what tells "
177
+ "your app the node can be removed, and a forever animation never completes"
178
+ )
179
+
180
+ self.trigger = trigger
181
+ # Normalised here, not in `to_wire`, so that a malformed value — a three-long
182
+ # tuple, a string where a number belongs — raises at the call site the author
183
+ # typed rather than on the first render of a screen they may not be looking
184
+ # at. `to_wire` is then pure formatting, which is the point of doing it once.
185
+ self.channels = {name: _from_to(name, value) for name, value in channels.items()}
186
+ self.ms = ms
187
+ self.ease = ease
188
+ self.spring = spring
189
+ self.delay = delay
190
+ self.repeat = repeat
191
+ self.dir = dir
192
+
193
+ @classmethod
194
+ def enter(cls, **kw: Any) -> "Anim":
195
+ """Run once, when this node first appears with this declaration on it.
196
+
197
+ Idempotent by construction: the tree is rebuilt on every frame of a scroll, so
198
+ this declaration arrives ~60 times a second and the driver starts it once.
199
+ """
200
+ return cls("enter", **kw)
201
+
202
+ @classmethod
203
+ def change(cls, **kw: Any) -> "Anim":
204
+ """Follow a moving target — re-runs whenever the target value changes.
205
+
206
+ Interrupting carries the current position across (and, for a spring, the
207
+ current *velocity*), so the value never jumps. A control the user can grab
208
+ mid-flight should therefore be a `spring=`.
209
+ """
210
+ return cls("change", **kw)
211
+
212
+ @classmethod
213
+ def exit_(cls, **kw: Any) -> "Anim":
214
+ """Run once, and tell the app when it is done so the node can be removed.
215
+
216
+ The renderer cannot animate a node out of a tree it is no longer in — the tree
217
+ is authoritative, so a removed node is simply gone. The contract is therefore
218
+ the other way round: **your `view()` keeps rendering the node** with this
219
+ declaration on it, and drops it when `on_animend` fires. That is one Python
220
+ call per animation rather than one per frame (§45.2).
221
+
222
+ Named `exit_` because `Anim.exit` would shadow nothing but reads as the
223
+ builtin at a call site.
224
+ """
225
+ return cls("exit", **kw)
226
+
227
+ def to_wire(self) -> list[dict]:
228
+ """This declaration as the renderer's `anim` attribute — one entry per channel.
229
+
230
+ Flattened per channel rather than sent as one grouped record because the
231
+ driver's unit of work is `(node, channel)`: two channels on one node can be
232
+ retargeted, completed and evicted independently, and a grouped wire format
233
+ would have to be un-grouped on arrival anyway.
234
+ """
235
+ out = []
236
+ for name, (frm, to) in self.channels.items():
237
+ entry: dict[str, Any] = {"ch": name}
238
+ entry["to"] = to
239
+ if frm is not None:
240
+ entry["from"] = frm
241
+ if self.ms is not None:
242
+ entry["ms"] = float(self.ms)
243
+ if self.ease is not None:
244
+ entry["ease"] = self.ease if isinstance(self.ease, str) else [float(v) for v in self.ease]
245
+ else:
246
+ assert self.spring is not None # guaranteed by __init__
247
+ entry["spring"] = [float(v) for v in self.spring]
248
+ if self.delay:
249
+ entry["delay"] = float(self.delay)
250
+ if self.repeat is not None:
251
+ entry["repeat"] = self.repeat
252
+ if self.dir is not None:
253
+ entry["dir"] = self.dir
254
+ # `on` is omitted for the default so the common case sends fewer bytes —
255
+ # this attribute rides inside every rebuilt tree, and §43 measured what
256
+ # tree JSON costs on a rebuild frame.
257
+ if self.trigger != "enter":
258
+ entry["on"] = self.trigger
259
+ out.append(entry)
260
+ return out
261
+
262
+ def opens_a_layer(self) -> bool:
263
+ """Whether this declaration animates `opacity`, which costs an offscreen
264
+ render target for the node's whole subtree — §46.6 measured 1.88ms/frame for
265
+ one spanning the full screen."""
266
+ return "opacity" in self.channels
267
+
268
+
269
+ def _ctor_name(trigger: str) -> str:
270
+ return "exit_" if trigger == "exit" else trigger
271
+
272
+
273
+ def _from_to(name: str, value: Any) -> tuple[float | None, float]:
274
+ """`opacity=1` -> (None, 1.0); `opacity=(0, 1)` -> (0.0, 1.0)."""
275
+ if isinstance(value, (tuple, list)):
276
+ if len(value) != 2:
277
+ raise ValueError(
278
+ f"channel {name!r} takes a target or a (from, to) pair, got {len(value)} values"
279
+ )
280
+ return float(value[0]), float(value[1])
281
+ return None, float(value)
282
+
283
+
284
+ def to_wire(anim: Any) -> list[dict]:
285
+ """Normalise whatever an author passed as `anim=` into the wire list.
286
+
287
+ Accepts one `Anim`, a sequence of them, or `None`. A sequence is the multi-trigger
288
+ case — `[Anim.enter(...), Anim.exit_(...)]` on one node is how a sheet declares
289
+ both halves of its transition.
290
+ """
291
+ if anim is None:
292
+ return []
293
+ if isinstance(anim, Anim):
294
+ return anim.to_wire()
295
+ if isinstance(anim, Iterable) and not isinstance(anim, (str, bytes, dict)):
296
+ out: list[dict] = []
297
+ for item in anim:
298
+ if not isinstance(item, Anim):
299
+ raise TypeError(f"anim= takes Anim objects, got {type(item).__name__}")
300
+ out.extend(item.to_wire())
301
+ return out
302
+ raise TypeError(f"anim= takes an Anim or a list of them, got {type(anim).__name__}")
303
+
304
+
305
+ def warn_html_backend_once() -> None:
306
+ """Say, exactly once per process, that animations do not run on this backend.
307
+
308
+ **This is the honest state of Queue F after part 2, made loud instead of silent.**
309
+ The driver is native-only; the WebView port is F3 (§45.3's layer 4) and does not
310
+ exist, so a declaration renders a static node there and animates on native. That is
311
+ a §15.1 divergence, and this document's own record is that the dangerous ones are
312
+ the *quiet* ones — every entry in that section is something that worked on one
313
+ backend and did nothing on the other with no error anywhere.
314
+
315
+ Written to stderr, which every shell already tees to logcat and which
316
+ `zkite.errors.log_error` uses for the same reason (§19.3). Once per process rather
317
+ than per render, because `view()` runs sixty times a second.
318
+ """
319
+ global _warned_html_backend
320
+ if _warned_html_backend:
321
+ return
322
+ _warned_html_backend = True
323
+ print(
324
+ "ZKITEANIM this app declares an animation and is running on the WebView backend, "
325
+ "where the animation driver does not exist yet (phase_3/PLAN.md §47, layer 4). "
326
+ "The node renders in its final state and no on_animend will fire. "
327
+ "Run with --backend native to see it animate.",
328
+ file=sys.stderr,
329
+ )
330
+
331
+
332
+ def _reset_html_warning_for_tests() -> None:
333
+ global _warned_html_backend
334
+ _warned_html_backend = False
335
+
336
+
337
+ __all__ = ["Anim", "AnimEnd", "CHANNELS", "parse_anim_end", "to_wire", "warn_html_backend_once"]