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,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,4 @@
1
+ """Kivy host of nativelayout (the core is `nativelayout.core`)."""
2
+ from .native_view import NativeView, file_loader, run_app, system_is_dark
3
+
4
+ __all__ = ["NativeView", "file_loader", "run_app", "system_is_dark"]
@@ -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))