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,406 @@
|
|
|
1
|
+
"""Native tree and style (port of NativeTree.kt).
|
|
2
|
+
|
|
3
|
+
Colors: unsigned ARGB integers (0xAARRGGBB). 0 = transparent.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum, auto
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Length:
|
|
13
|
+
value: float
|
|
14
|
+
scalable: bool = False
|
|
15
|
+
|
|
16
|
+
def to_pixels(self, density: float, font_scale: float) -> float:
|
|
17
|
+
if self.scalable:
|
|
18
|
+
return self.value * 16.0 * density * font_scale
|
|
19
|
+
return self.value * density
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def rem(value: float) -> "Length":
|
|
23
|
+
return Length(value, True)
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def px(value: float) -> "Length":
|
|
27
|
+
return Length(value, False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
Length.ZERO = Length(0.0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Dim:
|
|
35
|
+
length: Length
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Percent:
|
|
40
|
+
value: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Auto:
|
|
44
|
+
_inst = None
|
|
45
|
+
|
|
46
|
+
def __new__(cls):
|
|
47
|
+
if cls._inst is None:
|
|
48
|
+
cls._inst = super().__new__(cls)
|
|
49
|
+
return cls._inst
|
|
50
|
+
|
|
51
|
+
def __repr__(self):
|
|
52
|
+
return "Auto"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
AUTO = _Auto()
|
|
56
|
+
Size = Dim | Percent | _Auto
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Direction(Enum):
|
|
60
|
+
COLUMN = auto()
|
|
61
|
+
ROW = auto()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Justify(Enum):
|
|
65
|
+
START = auto()
|
|
66
|
+
CENTER = auto()
|
|
67
|
+
END = auto()
|
|
68
|
+
BETWEEN = auto()
|
|
69
|
+
AROUND = auto()
|
|
70
|
+
EVENLY = auto()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Align(Enum):
|
|
74
|
+
START = auto()
|
|
75
|
+
CENTER = auto()
|
|
76
|
+
END = auto()
|
|
77
|
+
STRETCH = auto()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Dock(Enum):
|
|
81
|
+
"""Where a <footer> sits: BOTTOM (the default) takes its space, the others float over the screen."""
|
|
82
|
+
BOTTOM = auto()
|
|
83
|
+
TOP = auto()
|
|
84
|
+
LEFT = auto()
|
|
85
|
+
RIGHT = auto()
|
|
86
|
+
CENTER = auto()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Position(Enum):
|
|
90
|
+
"""`position`: STATIC stays in the flow of its parent; ABSOLUTE leaves it and is placed by top, right, bottom, left."""
|
|
91
|
+
STATIC = auto()
|
|
92
|
+
ABSOLUTE = auto()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ImageFit(Enum):
|
|
96
|
+
"""How an image fills its box: COVER fills and crops, CONTAIN fits whole, FILL stretches."""
|
|
97
|
+
COVER = auto()
|
|
98
|
+
CONTAIN = auto()
|
|
99
|
+
FILL = auto()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ImagePos(Enum):
|
|
103
|
+
"""Which part of a cover or contain image stays in view, or the side where it sits."""
|
|
104
|
+
CENTER = auto()
|
|
105
|
+
TOP = auto()
|
|
106
|
+
BOTTOM = auto()
|
|
107
|
+
LEFT = auto()
|
|
108
|
+
RIGHT = auto()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TextAlign(Enum):
|
|
112
|
+
START = auto()
|
|
113
|
+
CENTER = auto()
|
|
114
|
+
END = auto()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class FieldType(Enum):
|
|
118
|
+
TEXT = auto()
|
|
119
|
+
PASSWORD = auto()
|
|
120
|
+
NUMBER = auto()
|
|
121
|
+
AREA = auto()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TextTransform(Enum):
|
|
125
|
+
NONE = auto()
|
|
126
|
+
UPPERCASE = auto()
|
|
127
|
+
LOWERCASE = auto()
|
|
128
|
+
CAPITALIZE = auto()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class FontFamily(Enum):
|
|
132
|
+
DEFAULT = auto()
|
|
133
|
+
SANS = auto()
|
|
134
|
+
SERIF = auto()
|
|
135
|
+
MONOSPACE = auto()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class AnimKind(Enum):
|
|
139
|
+
NONE = auto()
|
|
140
|
+
FADE = auto()
|
|
141
|
+
SLIDE_UP = auto()
|
|
142
|
+
SLIDE_LEFT = auto()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class Sides:
|
|
147
|
+
top: Length = Length.ZERO
|
|
148
|
+
right: Length = Length.ZERO
|
|
149
|
+
bottom: Length = Length.ZERO
|
|
150
|
+
left: Length = Length.ZERO
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class Corners:
|
|
155
|
+
"""CSS order: top-left, top-right, bottom-right, bottom-left."""
|
|
156
|
+
top_left: Length = Length.ZERO
|
|
157
|
+
top_right: Length = Length.ZERO
|
|
158
|
+
bottom_right: Length = Length.ZERO
|
|
159
|
+
bottom_left: Length = Length.ZERO
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Colors:
|
|
163
|
+
BLACK = 0xFF000000
|
|
164
|
+
WHITE = 0xFFFFFFFF
|
|
165
|
+
BUTTON_GRAY = 0xFFE0E0E0
|
|
166
|
+
BORDER_GRAY = 0xFF767676
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class LineHeightMultiple:
|
|
171
|
+
value: float
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class LineHeightFixed:
|
|
176
|
+
length: Length
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
LineHeight = LineHeightMultiple | LineHeightFixed
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class Gradient:
|
|
184
|
+
"""angle in CSS degrees (0 = to top, 90 = to right, 180 = to bottom)."""
|
|
185
|
+
angle: int
|
|
186
|
+
colors: tuple[int, ...]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class Animation:
|
|
191
|
+
kind: AnimKind
|
|
192
|
+
duration_ms: int = 200
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class DefaultAnimations:
|
|
196
|
+
SCREEN = Animation(AnimKind.FADE, 220)
|
|
197
|
+
LAYER = Animation(AnimKind.FADE, 160)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class StateStyle:
|
|
202
|
+
background_color: int | None = None
|
|
203
|
+
text_color: int | None = None
|
|
204
|
+
border_color: int | None = None
|
|
205
|
+
opacity: float | None = None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True)
|
|
209
|
+
class StateStyles:
|
|
210
|
+
pressed: StateStyle | None = None
|
|
211
|
+
disabled: StateStyle | None = None
|
|
212
|
+
focused: StateStyle | None = None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True)
|
|
216
|
+
class NativeStyle:
|
|
217
|
+
text_color: int = Colors.BLACK
|
|
218
|
+
background_color: int = 0
|
|
219
|
+
font_size: Length = field(default_factory=lambda: Length.rem(1.0))
|
|
220
|
+
bold: bool = False
|
|
221
|
+
text_align: TextAlign = TextAlign.START
|
|
222
|
+
width: Size = AUTO
|
|
223
|
+
height: Size = AUTO
|
|
224
|
+
padding: Sides = field(default_factory=Sides)
|
|
225
|
+
margin: Sides = field(default_factory=Sides)
|
|
226
|
+
border_radius: Length = Length.ZERO
|
|
227
|
+
corners: Corners | None = None
|
|
228
|
+
border_width: Length = Length.ZERO
|
|
229
|
+
border_color: int = Colors.BLACK
|
|
230
|
+
direction: Direction = Direction.COLUMN
|
|
231
|
+
justify: Justify = Justify.START
|
|
232
|
+
align_items: Align = Align.STRETCH
|
|
233
|
+
gap: Length = Length.ZERO
|
|
234
|
+
flex: float = 0.0
|
|
235
|
+
opacity: float = 1.0
|
|
236
|
+
italic: bool = False
|
|
237
|
+
underline: bool = False
|
|
238
|
+
strike: bool = False
|
|
239
|
+
letter_spacing: Length = Length.ZERO
|
|
240
|
+
line_height: LineHeight | None = None
|
|
241
|
+
text_transform: TextTransform = TextTransform.NONE
|
|
242
|
+
font_family: FontFamily = FontFamily.DEFAULT
|
|
243
|
+
min_width: Length | None = None
|
|
244
|
+
max_width: Length | None = None
|
|
245
|
+
min_height: Length | None = None
|
|
246
|
+
max_height: Length | None = None
|
|
247
|
+
overflow_hidden: bool = False
|
|
248
|
+
align_self: Align | None = None
|
|
249
|
+
dock: Dock | None = None
|
|
250
|
+
position: Position = Position.STATIC
|
|
251
|
+
top: Length | None = None
|
|
252
|
+
right: Length | None = None
|
|
253
|
+
bottom: Length | None = None
|
|
254
|
+
left: Length | None = None
|
|
255
|
+
z_index: int = 0
|
|
256
|
+
# name of a background image, resolved by the host like the source of an <img>
|
|
257
|
+
background_image: str | None = None
|
|
258
|
+
background_size: ImageFit = ImageFit.COVER
|
|
259
|
+
background_position: ImagePos = ImagePos.CENTER
|
|
260
|
+
object_fit: ImageFit = ImageFit.CONTAIN
|
|
261
|
+
wrap: bool = False
|
|
262
|
+
elevation: Length = Length.ZERO
|
|
263
|
+
gradient: Gradient | None = None
|
|
264
|
+
animation: Animation | None = None
|
|
265
|
+
states: StateStyles | None = None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True)
|
|
269
|
+
class Span:
|
|
270
|
+
text: str
|
|
271
|
+
bold: bool
|
|
272
|
+
color: int
|
|
273
|
+
size: Length
|
|
274
|
+
italic: bool = False
|
|
275
|
+
underline: bool = False
|
|
276
|
+
strike: bool = False
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(eq=False, kw_only=True)
|
|
280
|
+
class Native:
|
|
281
|
+
id: str | None
|
|
282
|
+
classes: list[str]
|
|
283
|
+
style: NativeStyle
|
|
284
|
+
line: int
|
|
285
|
+
key: str = ""
|
|
286
|
+
key_hint: str | None = None
|
|
287
|
+
scope: str | None = None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass(eq=False, kw_only=True)
|
|
291
|
+
class NativeBox(Native):
|
|
292
|
+
children: list[Native]
|
|
293
|
+
virtual: bool = False
|
|
294
|
+
overlay: bool = False # <dialog>
|
|
295
|
+
header: bool = False # <header>
|
|
296
|
+
footer: bool = False # <footer>
|
|
297
|
+
# True when a child is `position: absolute`: the host gives the box a frame for them
|
|
298
|
+
has_absolute: bool = False
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(eq=False, kw_only=True)
|
|
302
|
+
class NativeText(Native):
|
|
303
|
+
spans: list[Span]
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def plain_text(self) -> str:
|
|
307
|
+
return "".join(s.text for s in self.spans)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass(eq=False, kw_only=True)
|
|
311
|
+
class NativeButton(Native):
|
|
312
|
+
label: str
|
|
313
|
+
disabled: bool
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass(eq=False, kw_only=True)
|
|
317
|
+
class NativeField(Native):
|
|
318
|
+
type: FieldType
|
|
319
|
+
hint: str
|
|
320
|
+
value: str
|
|
321
|
+
disabled: bool
|
|
322
|
+
read_only: bool
|
|
323
|
+
rows: int
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclass(eq=False, kw_only=True)
|
|
327
|
+
class NativeCheckbox(Native):
|
|
328
|
+
checked: bool
|
|
329
|
+
disabled: bool
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass(eq=False, kw_only=True)
|
|
333
|
+
class NativeImage(Native):
|
|
334
|
+
source: str
|
|
335
|
+
description: str
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@dataclass(eq=False, kw_only=True)
|
|
339
|
+
class NativeItem(Native):
|
|
340
|
+
marker: str
|
|
341
|
+
children: list[Native]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@dataclass(eq=False, kw_only=True)
|
|
345
|
+
class NativeList(Native):
|
|
346
|
+
items: list[NativeItem]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def native_children(n: Native) -> list[Native]:
|
|
350
|
+
if isinstance(n, NativeBox):
|
|
351
|
+
return n.children
|
|
352
|
+
if isinstance(n, NativeList):
|
|
353
|
+
return n.items
|
|
354
|
+
if isinstance(n, NativeItem):
|
|
355
|
+
return n.children
|
|
356
|
+
return []
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def describe(n: Native, level: int = 0) -> str:
|
|
360
|
+
id_text = f" #{n.id}" if n.id is not None else ""
|
|
361
|
+
cls_text = " ." + " ".join(n.classes) if n.classes else ""
|
|
362
|
+
if isinstance(n, NativeText):
|
|
363
|
+
detail = f' "{n.plain_text}"'
|
|
364
|
+
elif isinstance(n, NativeButton):
|
|
365
|
+
detail = f' "{n.label}"'
|
|
366
|
+
elif isinstance(n, NativeField):
|
|
367
|
+
detail = f" {n.type.name}"
|
|
368
|
+
elif isinstance(n, NativeImage):
|
|
369
|
+
detail = f" {n.source}"
|
|
370
|
+
else:
|
|
371
|
+
detail = ""
|
|
372
|
+
out = " " * level + type(n).__name__ + id_text + cls_text + detail + "\n"
|
|
373
|
+
return out + "".join(describe(c, level + 1) for c in native_children(n))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def same_as(a: Native, o: Native) -> bool:
|
|
377
|
+
if a is o:
|
|
378
|
+
return True
|
|
379
|
+
if (type(a) is not type(o) or a.key != o.key or a.id != o.id
|
|
380
|
+
or a.classes != o.classes or a.style != o.style):
|
|
381
|
+
return False
|
|
382
|
+
if isinstance(a, NativeText):
|
|
383
|
+
same = a.spans == o.spans
|
|
384
|
+
elif isinstance(a, NativeButton):
|
|
385
|
+
same = a.label == o.label and a.disabled == o.disabled
|
|
386
|
+
elif isinstance(a, NativeField):
|
|
387
|
+
same = (a.type == o.type and a.hint == o.hint and a.value == o.value
|
|
388
|
+
and a.disabled == o.disabled and a.read_only == o.read_only and a.rows == o.rows)
|
|
389
|
+
elif isinstance(a, NativeCheckbox):
|
|
390
|
+
same = a.checked == o.checked and a.disabled == o.disabled
|
|
391
|
+
elif isinstance(a, NativeImage):
|
|
392
|
+
same = a.source == o.source and a.description == o.description
|
|
393
|
+
elif isinstance(a, NativeItem):
|
|
394
|
+
same = a.marker == o.marker
|
|
395
|
+
elif isinstance(a, NativeBox):
|
|
396
|
+
same = (a.virtual == o.virtual and a.overlay == o.overlay
|
|
397
|
+
and a.header == o.header and a.footer == o.footer
|
|
398
|
+
and a.has_absolute == o.has_absolute)
|
|
399
|
+
else:
|
|
400
|
+
same = True
|
|
401
|
+
if not same:
|
|
402
|
+
return False
|
|
403
|
+
ca, cb = native_children(a), native_children(o)
|
|
404
|
+
if len(ca) != len(cb):
|
|
405
|
+
return False
|
|
406
|
+
return all(same_as(x, y) for x, y in zip(ca, cb))
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Drawing with Kivy: colors, shapes with rounded corners, gradients and icons."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
from kivy.graphics import (Color, Ellipse, Line, Rectangle, RoundedRectangle, StencilPop, StencilPush,
|
|
7
|
+
StencilUnUse, StencilUse)
|
|
8
|
+
from kivy.graphics.texture import Texture
|
|
9
|
+
|
|
10
|
+
from nativelayout.core import icons as IconSet
|
|
11
|
+
from nativelayout.core.tree import Corners, ImageFit, ImagePos, Length, NativeStyle
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def rgba(c: int, opacity: float = 1.0) -> tuple[float, float, float, float]:
|
|
15
|
+
"""Unsigned ARGB integer -> (r, g, b, a) from 0 to 1."""
|
|
16
|
+
return (((c >> 16) & 255) / 255.0, ((c >> 8) & 255) / 255.0, (c & 255) / 255.0,
|
|
17
|
+
((c >> 24) & 255) / 255.0 * opacity)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def radii_of(style: NativeStyle, px) -> list[float]:
|
|
21
|
+
"""[top-left, top-right, bottom-right, bottom-left] in pixels."""
|
|
22
|
+
c: Corners | None = style.corners
|
|
23
|
+
if c is not None:
|
|
24
|
+
return [px(c.top_left), px(c.top_right), px(c.bottom_right), px(c.bottom_left)]
|
|
25
|
+
r = px(style.border_radius)
|
|
26
|
+
return [r, r, r, r]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_gradient_cache: dict = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def gradient_texture(angle: int, colors: tuple[int, ...], size: int = 48) -> Texture:
|
|
33
|
+
"""Small texture with the CSS gradient (angle in degrees, 0 = up, 90 = to the right)."""
|
|
34
|
+
key = (angle, colors, size)
|
|
35
|
+
tex = _gradient_cache.get(key)
|
|
36
|
+
if tex is not None:
|
|
37
|
+
return tex
|
|
38
|
+
rad = math.radians(angle)
|
|
39
|
+
dx, dy = math.sin(rad), -math.cos(rad) # direction in screen coordinates (y down)
|
|
40
|
+
half = (abs(dx) + abs(dy)) / 2.0 or 1.0
|
|
41
|
+
stops = [rgba(c) for c in colors]
|
|
42
|
+
n = len(stops) - 1
|
|
43
|
+
buf = bytearray()
|
|
44
|
+
for row in range(size): # a Kivy texture has its origin at the bottom
|
|
45
|
+
sy = (size - 1 - row) / (size - 1) - 0.5
|
|
46
|
+
for col in range(size):
|
|
47
|
+
sx = col / (size - 1) - 0.5
|
|
48
|
+
t = min(max((sx * dx + sy * dy) / (2 * half) + 0.5, 0.0), 1.0)
|
|
49
|
+
f = t * n
|
|
50
|
+
i = min(int(f), n - 1)
|
|
51
|
+
k = f - i
|
|
52
|
+
a, b = stops[i], stops[i + 1]
|
|
53
|
+
for ch in range(4):
|
|
54
|
+
buf.append(int(255 * (a[ch] + (b[ch] - a[ch]) * k)))
|
|
55
|
+
tex = Texture.create(size=(size, size), colorfmt="rgba")
|
|
56
|
+
tex.blit_buffer(bytes(buf), colorfmt="rgba", bufferfmt="ubyte")
|
|
57
|
+
tex.mag_filter = tex.min_filter = "linear"
|
|
58
|
+
if len(_gradient_cache) > 64:
|
|
59
|
+
_gradient_cache.clear()
|
|
60
|
+
_gradient_cache[key] = tex
|
|
61
|
+
return tex
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def draw_box(canvas, x: float, y: float, w: float, h: float, style: NativeStyle, px,
|
|
65
|
+
background: int | None = None, border: int | None = None, opacity: float = 1.0,
|
|
66
|
+
image: Texture | None = None) -> None:
|
|
67
|
+
"""Background, gradient, background image, shadow and border of a box. (x, y) is the bottom-left corner (Kivy)."""
|
|
68
|
+
if w <= 0 or h <= 0:
|
|
69
|
+
return
|
|
70
|
+
radii = radii_of(style, px)
|
|
71
|
+
r = [min(v, w / 2, h / 2) for v in radii]
|
|
72
|
+
elevation = px(style.elevation)
|
|
73
|
+
if elevation > 0.5 and (style.background_color or style.gradient or background):
|
|
74
|
+
steps = 4
|
|
75
|
+
for i in range(steps, 0, -1):
|
|
76
|
+
grow = elevation * i / steps
|
|
77
|
+
canvas.add(Color(0, 0, 0, 0.06 * opacity))
|
|
78
|
+
canvas.add(RoundedRectangle(pos=(x - grow * 0.4, y - grow * 0.9),
|
|
79
|
+
size=(w + grow * 0.8, h + grow * 0.8),
|
|
80
|
+
radius=[v + grow * 0.4 for v in r]))
|
|
81
|
+
if background is None and style.gradient is not None:
|
|
82
|
+
g = style.gradient
|
|
83
|
+
canvas.add(Color(1, 1, 1, opacity))
|
|
84
|
+
canvas.add(RoundedRectangle(pos=(x, y), size=(w, h), radius=r,
|
|
85
|
+
texture=gradient_texture(g.angle, g.colors)))
|
|
86
|
+
else:
|
|
87
|
+
bg = background if background is not None else style.background_color
|
|
88
|
+
if bg:
|
|
89
|
+
canvas.add(Color(*rgba(bg, opacity)))
|
|
90
|
+
canvas.add(RoundedRectangle(pos=(x, y), size=(w, h), radius=r))
|
|
91
|
+
if image is not None:
|
|
92
|
+
draw_image(canvas, image, x, y, w, h, style.background_size, style.background_position, r, opacity)
|
|
93
|
+
bw = px(style.border_width)
|
|
94
|
+
if bw > 0:
|
|
95
|
+
bw = max(1.0, bw)
|
|
96
|
+
canvas.add(Color(*rgba(border if border is not None else style.border_color, opacity)))
|
|
97
|
+
inset = bw / 2
|
|
98
|
+
canvas.add(Line(rounded_rectangle=(x + inset, y + inset, w - bw, h - bw,
|
|
99
|
+
*[max(v - inset, 0) for v in r]), width=bw))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _sub_tex_coords(tex: Texture, a0: float, a1: float, b0: float, b1: float) -> tuple:
|
|
103
|
+
"""Texture coordinates of the part of `tex` between a0..a1 (left to right) and b0..b1 (bottom to top).
|
|
104
|
+
|
|
105
|
+
Uses the coordinates the texture already has, so it works whatever its orientation (loaded images are flipped).
|
|
106
|
+
"""
|
|
107
|
+
u_bl, v_bl, u_br, v_br, u_tr, v_tr, u_tl, v_tl = tex.tex_coords
|
|
108
|
+
|
|
109
|
+
def at(a: float, b: float) -> tuple[float, float]:
|
|
110
|
+
u = u_bl * (1 - a) * (1 - b) + u_br * a * (1 - b) + u_tr * a * b + u_tl * (1 - a) * b
|
|
111
|
+
v = v_bl * (1 - a) * (1 - b) + v_br * a * (1 - b) + v_tr * a * b + v_tl * (1 - a) * b
|
|
112
|
+
return u, v
|
|
113
|
+
|
|
114
|
+
return (*at(a0, b0), *at(a1, b0), *at(a1, b1), *at(a0, b1))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def draw_image(canvas, tex: Texture, x: float, y: float, w: float, h: float, fit: ImageFit,
|
|
118
|
+
pos: ImagePos = ImagePos.CENTER, radii: list[float] | None = None, opacity: float = 1.0) -> None:
|
|
119
|
+
"""Draws `tex` in the box (x, y, w, h), (x, y) being the bottom-left corner.
|
|
120
|
+
|
|
121
|
+
COVER fills the box and cuts what does not fit, CONTAIN shows the whole image, FILL stretches it.
|
|
122
|
+
`pos` says which part stays in view (cover) or on which side the image sits (contain). It follows
|
|
123
|
+
the Android host (ImageBackground). COVER and FILL follow the rounded corners given in `radii`.
|
|
124
|
+
"""
|
|
125
|
+
tw, th = tex.size
|
|
126
|
+
if tw <= 0 or th <= 0 or w <= 0 or h <= 0:
|
|
127
|
+
return
|
|
128
|
+
canvas.add(Color(1, 1, 1, opacity))
|
|
129
|
+
rounded = radii is not None and any(r > 0 for r in radii)
|
|
130
|
+
|
|
131
|
+
def box(coords, px_, py_, pw_, ph_, round_corners):
|
|
132
|
+
if round_corners:
|
|
133
|
+
# RoundedRectangle ignores a cut of the texture, so the image goes in a plain rectangle
|
|
134
|
+
# and a stencil mask with the rounded corners cuts it
|
|
135
|
+
canvas.add(StencilPush())
|
|
136
|
+
canvas.add(RoundedRectangle(pos=(px_, py_), size=(pw_, ph_), radius=radii))
|
|
137
|
+
canvas.add(StencilUse())
|
|
138
|
+
canvas.add(Color(1, 1, 1, opacity))
|
|
139
|
+
canvas.add(Rectangle(texture=tex, tex_coords=coords, pos=(px_, py_), size=(pw_, ph_)))
|
|
140
|
+
canvas.add(StencilUnUse())
|
|
141
|
+
canvas.add(RoundedRectangle(pos=(px_, py_), size=(pw_, ph_), radius=radii))
|
|
142
|
+
canvas.add(StencilPop())
|
|
143
|
+
else:
|
|
144
|
+
canvas.add(Rectangle(texture=tex, tex_coords=coords, pos=(px_, py_), size=(pw_, ph_)))
|
|
145
|
+
|
|
146
|
+
if fit == ImageFit.FILL:
|
|
147
|
+
box(_sub_tex_coords(tex, 0, 1, 0, 1), x, y, w, h, rounded)
|
|
148
|
+
elif fit == ImageFit.COVER:
|
|
149
|
+
scale = max(w / tw, h / th)
|
|
150
|
+
sw, sh = w / scale, h / scale # part of the image that stays in view, in image pixels
|
|
151
|
+
sx = 0.0 if pos == ImagePos.LEFT else (tw - sw) if pos == ImagePos.RIGHT else (tw - sw) / 2
|
|
152
|
+
sy = 0.0 if pos == ImagePos.TOP else (th - sh) if pos == ImagePos.BOTTOM else (th - sh) / 2 # from the top
|
|
153
|
+
coords = _sub_tex_coords(tex, sx / tw, (sx + sw) / tw, 1 - (sy + sh) / th, 1 - sy / th)
|
|
154
|
+
box(coords, x, y, w, h, rounded)
|
|
155
|
+
else:
|
|
156
|
+
scale = min(w / tw, h / th)
|
|
157
|
+
dw, dh = tw * scale, th * scale
|
|
158
|
+
dx = 0.0 if pos == ImagePos.LEFT else (w - dw) if pos == ImagePos.RIGHT else (w - dw) / 2
|
|
159
|
+
dy = 0.0 if pos == ImagePos.TOP else (h - dh) if pos == ImagePos.BOTTOM else (h - dh) / 2 # from the top
|
|
160
|
+
box(_sub_tex_coords(tex, 0, 1, 0, 1), x + dx, y + h - dy - dh, dw, dh, False)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def draw_icon(canvas, name: str, x: float, y: float, size: float, color: int, opacity: float = 1.0) -> None:
|
|
164
|
+
"""Draws a built-in icon (24x24 grid, stroke 2, round caps). (x, y) is the bottom-left corner."""
|
|
165
|
+
shapes = IconSet.shapes_of(name)
|
|
166
|
+
if shapes is None:
|
|
167
|
+
return
|
|
168
|
+
s = size / IconSet.GRID
|
|
169
|
+
top = y + size
|
|
170
|
+
|
|
171
|
+
def X(v): return x + v * s
|
|
172
|
+
def Y(v): return top - v * s
|
|
173
|
+
|
|
174
|
+
canvas.add(Color(*rgba(color, opacity)))
|
|
175
|
+
for sh in shapes:
|
|
176
|
+
if isinstance(sh, IconSet.Line):
|
|
177
|
+
pts = []
|
|
178
|
+
for i in range(0, len(sh.points), 2):
|
|
179
|
+
pts += [X(sh.points[i]), Y(sh.points[i + 1])]
|
|
180
|
+
canvas.add(Line(points=pts, width=IconSet.STROKE * s, cap="round", joint="round", close=sh.closed))
|
|
181
|
+
elif isinstance(sh, IconSet.Circle):
|
|
182
|
+
canvas.add(Line(circle=(X(sh.cx), Y(sh.cy), sh.r * s), width=IconSet.STROKE * s))
|
|
183
|
+
elif isinstance(sh, IconSet.Dot):
|
|
184
|
+
r = IconSet.DOT_RADIUS * s
|
|
185
|
+
canvas.add(Ellipse(pos=(X(sh.cx) - r, Y(sh.cy) - r), size=(2 * r, 2 * r)))
|
|
186
|
+
elif isinstance(sh, IconSet.Rect):
|
|
187
|
+
canvas.add(Line(rounded_rectangle=(X(sh.left), Y(sh.bottom), (sh.right - sh.left) * s,
|
|
188
|
+
(sh.bottom - sh.top) * s, sh.radius * s),
|
|
189
|
+
width=IconSet.STROKE * s))
|