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 +55 -0
- zkite/_anim.py +337 -0
- zkite/_nav.py +394 -0
- zkite/_store.py +94 -0
- zkite/_system_bars.py +59 -0
- zkite/_viewport.py +232 -0
- zkite/app.py +323 -0
- zkite/errors.py +105 -0
- zkite/reload.py +150 -0
- zkite/tailwind_config.py +36 -0
- zkite/widgets.py +1847 -0
- zkite_framework-0.1.0rc1.dist-info/METADATA +20 -0
- zkite_framework-0.1.0rc1.dist-info/RECORD +16 -0
- zkite_framework-0.1.0rc1.dist-info/WHEEL +4 -0
- zkite_framework-0.1.0rc1.dist-info/licenses/LICENSE +21 -0
- zkite_framework-0.1.0rc1.dist-info/licenses/THIRD_PARTY_NOTICES.txt +109733 -0
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"]
|