glance-widgets-sdk 1.0.0__tar.gz

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.
Files changed (40) hide show
  1. glance_widgets_sdk-1.0.0/LICENSE +17 -0
  2. glance_widgets_sdk-1.0.0/MANIFEST.in +2 -0
  3. glance_widgets_sdk-1.0.0/PKG-INFO +56 -0
  4. glance_widgets_sdk-1.0.0/README.md +44 -0
  5. glance_widgets_sdk-1.0.0/pyproject.toml +26 -0
  6. glance_widgets_sdk-1.0.0/setup.cfg +4 -0
  7. glance_widgets_sdk-1.0.0/src/glance_widgets/__init__.py +138 -0
  8. glance_widgets_sdk-1.0.0/src/glance_widgets/_opt.py +6 -0
  9. glance_widgets_sdk-1.0.0/src/glance_widgets/_shared.py +225 -0
  10. glance_widgets_sdk-1.0.0/src/glance_widgets/color.py +95 -0
  11. glance_widgets_sdk-1.0.0/src/glance_widgets/data/fonts/font-3x5.json +120 -0
  12. glance_widgets_sdk-1.0.0/src/glance_widgets/data/fonts/font-5x7.json +118 -0
  13. glance_widgets_sdk-1.0.0/src/glance_widgets/data/icons.json +5293 -0
  14. glance_widgets_sdk-1.0.0/src/glance_widgets/data/schemas.json +418 -0
  15. glance_widgets_sdk-1.0.0/src/glance_widgets/data/text-advances.json +120 -0
  16. glance_widgets_sdk-1.0.0/src/glance_widgets/font.py +191 -0
  17. glance_widgets_sdk-1.0.0/src/glance_widgets/geometry.py +293 -0
  18. glance_widgets_sdk-1.0.0/src/glance_widgets/heatmap.py +323 -0
  19. glance_widgets_sdk-1.0.0/src/glance_widgets/icons.py +24 -0
  20. glance_widgets_sdk-1.0.0/src/glance_widgets/layers.py +94 -0
  21. glance_widgets_sdk-1.0.0/src/glance_widgets/line_chart.py +431 -0
  22. glance_widgets_sdk-1.0.0/src/glance_widgets/matrix.py +332 -0
  23. glance_widgets_sdk-1.0.0/src/glance_widgets/modern.py +722 -0
  24. glance_widgets_sdk-1.0.0/src/glance_widgets/output.py +120 -0
  25. glance_widgets_sdk-1.0.0/src/glance_widgets/pixel_bar_chart.py +222 -0
  26. glance_widgets_sdk-1.0.0/src/glance_widgets/pixel_progress.py +274 -0
  27. glance_widgets_sdk-1.0.0/src/glance_widgets/pixel_text.py +129 -0
  28. glance_widgets_sdk-1.0.0/src/glance_widgets/pixelate.py +64 -0
  29. glance_widgets_sdk-1.0.0/src/glance_widgets/protocol.py +255 -0
  30. glance_widgets_sdk-1.0.0/src/glance_widgets/raster.py +174 -0
  31. glance_widgets_sdk-1.0.0/src/glance_widgets/signature.py +65 -0
  32. glance_widgets_sdk-1.0.0/src/glance_widgets/stat.py +324 -0
  33. glance_widgets_sdk-1.0.0/src/glance_widgets/status.py +204 -0
  34. glance_widgets_sdk-1.0.0/src/glance_widgets/text_matrix.py +13 -0
  35. glance_widgets_sdk-1.0.0/src/glance_widgets/themes.py +55 -0
  36. glance_widgets_sdk-1.0.0/src/glance_widgets_sdk.egg-info/PKG-INFO +56 -0
  37. glance_widgets_sdk-1.0.0/src/glance_widgets_sdk.egg-info/SOURCES.txt +38 -0
  38. glance_widgets_sdk-1.0.0/src/glance_widgets_sdk.egg-info/dependency_links.txt +1 -0
  39. glance_widgets_sdk-1.0.0/src/glance_widgets_sdk.egg-info/requires.txt +3 -0
  40. glance_widgets_sdk-1.0.0/src/glance_widgets_sdk.egg-info/top_level.txt +1 -0
@@ -0,0 +1,17 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Glance Widgets
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
@@ -0,0 +1,2 @@
1
+ prune tests
2
+ prune scripts
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: glance-widgets-sdk
3
+ Version: 1.0.0
4
+ Summary: SDK for generating Glance Widgets API responses
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: dev
10
+ Requires-Dist: build>=1; extra == "dev"
11
+ Dynamic: license-file
12
+
13
+ # Python SDK
14
+
15
+ Layer constructors return a `Composite`: `{layers, data}`. Declarations contain only id, type and dataPath; the addressed objects contain layout, content and style. Merge layer results and chart components with the same merge function, then finalize with toResponse (to_response in snake_case APIs). An explicit dataPath chooses the object location; otherwise it is derived from id.
16
+
17
+ `Matrix.to_part()` returns `{content: MatrixData}`. Place it with `place` / `Place` to give its node a frame and data path. The node's `content` holds `pixels`, `default`, `batch` and `layout`; matrix `style` is empty. Image and image-backed icon nodes use `content.url`. Bind an editor layer to a partial node in `data` without adding a remote declaration. Omitted or null values keep the saved editor value; objects merge field by field, arrays replace, and a supplied frame replaces the whole frame and must include width and height. Each response starts from the saved configuration.
18
+
19
+ Pure Python 3.9+, using only the standard library.
20
+
21
+ ```sh
22
+ python3 -m pip install glance-widgets-sdk
23
+ ```
24
+
25
+ ```python
26
+ import json
27
+ from glance_widgets import Box, merge, pixel_text_root, pixel_progress_bar, place, to_response
28
+
29
+ canvas = {"width": 338, "height": 158}
30
+ root = pixel_text_root("12:30", {"canvas": canvas, "theme": "led"})
31
+ frame = Box.canvas(canvas).sub(0.1, 0.75, 0.8, 0.15).to_frame()
32
+ bar = pixel_progress_bar(65, {"canvas": canvas, "frame": frame})
33
+ badge = place(bar, {"id": "progress", "frame": frame, "path": "data.progress"})
34
+ response = to_response(merge(root, badge))
35
+ body = json.dumps(response, ensure_ascii=False, separators=(",", ":"))
36
+ ```
37
+
38
+ API functions and methods use `snake_case`: `to_response`, `labeled_pixel_ring_progress`,
39
+ `Matrix.to_part()`, `Box.from_pt()`. Arguments and returned protocol objects are
40
+ dictionaries; their field names retain the TS/protocol spelling (`fontSizeRatio`,
41
+ `dataPath`, `onConflict`, etc.). `Box.canvas(canvas)` creates a layout box.
42
+ `themes["led"]` selects a theme, and custom pixel fonts use `define_font()`.
43
+
44
+ See the [shared guide](../../docs/guide.md) for options and composition rules.
45
+
46
+ Fonts, icons, validation schemas and the measured text advance table are bundled
47
+ as package data. Regenerate them from the checked-in TS blueprint/spec with:
48
+
49
+ ```sh
50
+ python3 sdks/python/scripts/generate_data.py
51
+ ```
52
+
53
+ This development command needs the TypeScript SDK's installed compiler. The
54
+ Python package itself does not invoke Node or depend on the repository layout.
55
+
56
+ Use `layer_path(id)` for the derived node path, and `validate_layer_node(node, type)` to validate a partial editor node before returning it.
@@ -0,0 +1,44 @@
1
+ # Python SDK
2
+
3
+ Layer constructors return a `Composite`: `{layers, data}`. Declarations contain only id, type and dataPath; the addressed objects contain layout, content and style. Merge layer results and chart components with the same merge function, then finalize with toResponse (to_response in snake_case APIs). An explicit dataPath chooses the object location; otherwise it is derived from id.
4
+
5
+ `Matrix.to_part()` returns `{content: MatrixData}`. Place it with `place` / `Place` to give its node a frame and data path. The node's `content` holds `pixels`, `default`, `batch` and `layout`; matrix `style` is empty. Image and image-backed icon nodes use `content.url`. Bind an editor layer to a partial node in `data` without adding a remote declaration. Omitted or null values keep the saved editor value; objects merge field by field, arrays replace, and a supplied frame replaces the whole frame and must include width and height. Each response starts from the saved configuration.
6
+
7
+ Pure Python 3.9+, using only the standard library.
8
+
9
+ ```sh
10
+ python3 -m pip install glance-widgets-sdk
11
+ ```
12
+
13
+ ```python
14
+ import json
15
+ from glance_widgets import Box, merge, pixel_text_root, pixel_progress_bar, place, to_response
16
+
17
+ canvas = {"width": 338, "height": 158}
18
+ root = pixel_text_root("12:30", {"canvas": canvas, "theme": "led"})
19
+ frame = Box.canvas(canvas).sub(0.1, 0.75, 0.8, 0.15).to_frame()
20
+ bar = pixel_progress_bar(65, {"canvas": canvas, "frame": frame})
21
+ badge = place(bar, {"id": "progress", "frame": frame, "path": "data.progress"})
22
+ response = to_response(merge(root, badge))
23
+ body = json.dumps(response, ensure_ascii=False, separators=(",", ":"))
24
+ ```
25
+
26
+ API functions and methods use `snake_case`: `to_response`, `labeled_pixel_ring_progress`,
27
+ `Matrix.to_part()`, `Box.from_pt()`. Arguments and returned protocol objects are
28
+ dictionaries; their field names retain the TS/protocol spelling (`fontSizeRatio`,
29
+ `dataPath`, `onConflict`, etc.). `Box.canvas(canvas)` creates a layout box.
30
+ `themes["led"]` selects a theme, and custom pixel fonts use `define_font()`.
31
+
32
+ See the [shared guide](../../docs/guide.md) for options and composition rules.
33
+
34
+ Fonts, icons, validation schemas and the measured text advance table are bundled
35
+ as package data. Regenerate them from the checked-in TS blueprint/spec with:
36
+
37
+ ```sh
38
+ python3 sdks/python/scripts/generate_data.py
39
+ ```
40
+
41
+ This development command needs the TypeScript SDK's installed compiler. The
42
+ Python package itself does not invoke Node or depend on the repository layout.
43
+
44
+ Use `layer_path(id)` for the derived node path, and `validate_layer_node(node, type)` to validate a partial editor node before returning it.
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "glance-widgets-sdk"
7
+ version = "1.0.0"
8
+ requires-python = ">=3.9"
9
+ description = "SDK for generating Glance Widgets API responses"
10
+ readme = "README.md"
11
+ license = { text = "MIT" }
12
+
13
+ [project.optional-dependencies]
14
+ dev = ["build>=1"]
15
+
16
+ [tool.setuptools]
17
+ package-dir = {"" = "src"}
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+
22
+ [tool.setuptools.package-data]
23
+ glance_widgets = ["data/*.json", "data/fonts/*.json"]
24
+
25
+ [tool.pytest.ini_options]
26
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,138 @@
1
+ """Glance Widgets SDK: native Python builders for protocol v1."""
2
+
3
+ from .color import (
4
+ parse_color,
5
+ format_color,
6
+ normalize_color,
7
+ is_app_color,
8
+ rgb,
9
+ with_alpha,
10
+ mix_colors,
11
+ )
12
+ from .geometry import (
13
+ Box,
14
+ require_canvas,
15
+ resolve_frame,
16
+ fit_matrix,
17
+ cell_rect,
18
+ column_boundary,
19
+ row_boundary,
20
+ to_ratio_rect,
21
+ solve_grid,
22
+ GAP_MODE_RATIOS,
23
+ FRAME_DECIMALS,
24
+ )
25
+ from .matrix import Matrix, EMPTY, DEFAULT_PIXEL_BASE, BATCH_MIN_OCCURRENCES
26
+ from .font import (
27
+ PixelFont,
28
+ define_font,
29
+ get_font,
30
+ resolve_font,
31
+ measure_text,
32
+ layout_text,
33
+ FONT_3X5,
34
+ FONT_5X7,
35
+ DEFAULT_FONT_NAME,
36
+ DEFAULT_LETTER_SPACING,
37
+ DEFAULT_LINE_SPACING,
38
+ ANCHOR_BOX_WIDTH,
39
+ )
40
+ from .text_matrix import text_matrix
41
+ from .themes import (
42
+ themes,
43
+ get_theme,
44
+ theme_options,
45
+ OFF_PIXEL_OPACITY,
46
+ ROOT_RING_OPACITY_THRESHOLD,
47
+ )
48
+ from .icons import has_icon, icon_names, icon_glyph, DEFAULT_ICON_NAME
49
+ from .layers import (
50
+ text,
51
+ line,
52
+ horizontal_line,
53
+ vertical_line,
54
+ shape,
55
+ icon,
56
+ image,
57
+ curve,
58
+ TEXT_DEFAULTS,
59
+ LINE_DEFAULTS,
60
+ SHAPE_DEFAULTS,
61
+ ICON_DEFAULTS,
62
+ IMAGE_DEFAULTS,
63
+ CURVE_DEFAULTS,
64
+ )
65
+ from .output import layer_path, layer_id, nest_at_path, merge, place, empty_root, to_response
66
+ from .protocol import (
67
+ validate,
68
+ resolve_data_path,
69
+ GlanceValidationError,
70
+ PROTOCOL_VERSION,
71
+ MAX_RESPONSE_BYTES,
72
+ MAX_MATRIX_CELLS,
73
+ LAYER_ID_PREFIX,
74
+ MAX_SAFE_NATIVE_INTEGER,
75
+ DATA_PATH_PATTERN,
76
+ CELL_ASPECT_RATIO_MIN,
77
+ CELL_ASPECT_RATIO_MAX,
78
+ EDGE_INSET_RATIO_LIMIT,
79
+ GAP_RATIO_MAX,
80
+ FRAME_SIZE_MIN,
81
+ FRAME_SIZE_MAX,
82
+ SHAPE_MIN_SIZE_PT,
83
+ )
84
+ from .raster import RASTER_MAX_EXTENT, RASTER_MAX_COORDINATE, ARC_DIRECTION_SCALE
85
+ from .signature import (
86
+ sign,
87
+ verify_signature,
88
+ TIMESTAMP_HEADER,
89
+ SIGNATURE_HEADER,
90
+ DEFAULT_SIGNATURE_TOLERANCE_MS,
91
+ )
92
+ from .pixelate import pixelate, DEFAULT_ALPHA_THRESHOLD
93
+ from .pixel_progress import (
94
+ pixel_progress_bar,
95
+ labeled_pixel_progress_bar,
96
+ pixel_ring_progress,
97
+ labeled_pixel_ring_progress,
98
+ PIXEL_PROGRESS_BAR_DEFAULTS,
99
+ LABELED_PIXEL_PROGRESS_BAR_DEFAULTS,
100
+ PIXEL_RING_PROGRESS_DEFAULTS,
101
+ LABELED_PIXEL_RING_PROGRESS_DEFAULTS,
102
+ )
103
+ from .modern import (
104
+ progress_bar,
105
+ ring_progress,
106
+ bar_chart,
107
+ battery,
108
+ signal,
109
+ arc_pieces,
110
+ PROGRESS_BAR_DEFAULTS,
111
+ RING_PROGRESS_DEFAULTS,
112
+ BAR_CHART_DEFAULTS,
113
+ BATTERY_DEFAULTS,
114
+ SIGNAL_DEFAULTS,
115
+ BAD_LEVEL,
116
+ WARN_LEVEL,
117
+ )
118
+ from .status import (
119
+ status_strip,
120
+ labeled_status_strip,
121
+ STATUS_STRIP_DEFAULTS,
122
+ LABELED_STATUS_STRIP_DEFAULTS,
123
+ )
124
+ from .pixel_text import pixel_text_panel, pixel_text_root, PIXEL_TEXT_DEFAULTS
125
+ from ._shared import STATUS_COLORS, DEFAULT_THEME_NAME, percent_label
126
+ from .heatmap import (
127
+ heatmap,
128
+ calendar_heatmap,
129
+ labeled_calendar_heatmap,
130
+ HEATMAP_DEFAULTS,
131
+ CALENDAR_HEATMAP_DEFAULTS,
132
+ LABELED_CALENDAR_HEATMAP_DEFAULTS,
133
+ )
134
+ from .pixel_bar_chart import pixel_bar_chart, PIXEL_BAR_CHART_DEFAULTS
135
+ from .line_chart import line_chart, area_chart, sparkline, LINE_CHART_DEFAULTS
136
+ from .stat import stat, STAT_DEFAULTS
137
+
138
+ from .protocol import validate_layer_node
@@ -0,0 +1,6 @@
1
+ """Option lookup where None means unset (TypeScript `??`)."""
2
+
3
+
4
+ def opt(options, key, default=None):
5
+ value = None if options is None else options.get(key)
6
+ return default if value is None else value
@@ -0,0 +1,225 @@
1
+ """Internal component layout and formatting helpers."""
2
+
3
+ import json
4
+ import math
5
+ from pathlib import Path
6
+ import re
7
+ from ._opt import opt
8
+ from .color import clamp, js_round
9
+ from .geometry import Box, require_canvas, resolve_frame, solve_grid
10
+ from .layers import text
11
+ from .themes import get_theme
12
+
13
+ DEFAULT_THEME_NAME = "lcd"
14
+ STATUS_COLORS = dict(good="#34C759", warn="#FF9F0A", bad="#FF453A", neutral="#8E8E93")
15
+ TEXT_LINE_HEIGHT = 1.2
16
+ LABEL_BOX_LINES = 1.5
17
+ LABEL_GAP_EM = 0.4
18
+ _advances = json.loads((Path(__file__).parent / "data/text-advances.json").read_text())
19
+
20
+
21
+ def resolve_theme(theme=None):
22
+ return (
23
+ get_theme(DEFAULT_THEME_NAME if theme is None else theme)
24
+ if theme is None or isinstance(theme, str)
25
+ else theme
26
+ )
27
+
28
+
29
+ def resolve_area(frame, canvas):
30
+ if frame is None:
31
+ return Box.canvas(canvas)
32
+ return (
33
+ Box(frame.left, frame.top, frame.width, frame.height, canvas)
34
+ if isinstance(frame, Box)
35
+ else Box.from_frame(frame, canvas)
36
+ )
37
+
38
+
39
+ def emitted_region(box, canvas):
40
+ frame = box.to_frame()
41
+ return dict(frame=frame, rect=resolve_frame(frame, canvas))
42
+
43
+
44
+ def region_of(options, what):
45
+ canvas, frame = options.get("canvas"), options.get("frame")
46
+ if canvas is None:
47
+ if isinstance(frame, Box) and frame._canvas is not None:
48
+ return frame.to_pt()
49
+ if frame is not None:
50
+ raise ValueError(what + ": a frame needs a canvas")
51
+ return None
52
+ return emitted_region(resolve_area(frame, require_canvas(canvas, what)), canvas)[
53
+ "rect"
54
+ ]
55
+
56
+
57
+ def plan_grid(region, size, theme, what):
58
+ if region is None:
59
+ if size.get("columns") is None or size.get("rows") is None:
60
+ raise ValueError(what + ": grid needs both counts without canvas")
61
+ return dict(
62
+ columns=size["columns"], rows=size["rows"], layout=dict(theme["layout"])
63
+ )
64
+ return solve_grid(
65
+ dict(
66
+ region=region,
67
+ **{k: v for k, v in size.items() if v is not None},
68
+ gapMode=theme["layout"]["gapMode"],
69
+ clampAspect=True,
70
+ )
71
+ )
72
+
73
+
74
+ def pixel_looks(theme, colors=None):
75
+ colors = colors or {}
76
+ off = (
77
+ {**theme["default"], "color": colors["off"]}
78
+ if colors.get("off")
79
+ else theme["default"]
80
+ )
81
+ return dict(
82
+ on=opt(colors, "on", theme["palette"]["on"]),
83
+ off="empty" if "off" in colors and colors["off"] is None else off,
84
+ )
85
+
86
+
87
+ def text_advance_em(content):
88
+ return (
89
+ sum(
90
+ (
91
+ _advances["ascii"][ord(c) - 32]
92
+ if 32 <= ord(c) < 127
93
+ else _advances["extra"].get(c, 1000)
94
+ )
95
+ + 40
96
+ for c in content
97
+ )
98
+ / 1000
99
+ )
100
+
101
+
102
+ def text_advance_pt(content, font_pt):
103
+ return text_advance_em(content) * font_pt
104
+
105
+
106
+ def estimate_text_width_pt(content, font_pt):
107
+ return text_advance_em(content) * 1.05 * font_pt + 1
108
+
109
+
110
+ def font_pt_for_width(content, width):
111
+ return max(0, width - 1) / max(1e-9, text_advance_em(content) * 1.05)
112
+
113
+
114
+ def text_box_height_pt(font_pt, lines=1):
115
+ return lines * 1.2 * font_pt + 1
116
+
117
+
118
+ def font_pt_of(ratio, canvas):
119
+ return ratio * min(canvas["width"], canvas["height"])
120
+
121
+
122
+ def font_ratio_of(pt, canvas):
123
+ return clamp(pt / min(canvas["width"], canvas["height"]), 0.02, 0.28)
124
+
125
+
126
+ def resolve_identity(options, default_id, what):
127
+ identity = opt(options, "id", default_id)
128
+ path = opt(options, "path")
129
+ if path is None and not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", identity):
130
+ raise ValueError(what + ": non-identifier id needs an explicit path")
131
+ return identity, path if path is not None else "data." + identity
132
+
133
+
134
+ def to_fixed(value, decimals):
135
+ """Round the exact IEEE double, with decimal ties away from zero (ECMAScript)."""
136
+ if isinstance(decimals, bool) or not isinstance(decimals, (int, float)):
137
+ raise ValueError("decimals must be a number in 0..100")
138
+ if decimals != decimals: # NaN -> 0 (ToIntegerOrInfinity)
139
+ decimals = 0
140
+ if not math.isfinite(decimals) or not 0 <= math.trunc(decimals) <= 100:
141
+ raise ValueError("decimals must be in 0..100")
142
+ decimals = math.trunc(decimals)
143
+ value = float(value)
144
+ if value != value:
145
+ return "NaN"
146
+ if math.isinf(value):
147
+ return "Infinity" if value > 0 else "-Infinity"
148
+ if abs(value) >= 1e21:
149
+ return repr(value)
150
+ numerator, denominator = abs(value).as_integer_ratio()
151
+ scaled = numerator * 10**decimals
152
+ rounded, remainder = divmod(scaled, denominator)
153
+ if remainder * 2 >= denominator:
154
+ rounded += 1
155
+ digits = str(rounded).rjust(decimals + 1, "0")
156
+ result = digits[:-decimals] + "." + digits[-decimals:] if decimals else digits
157
+ return ("-" if value < 0 else "") + result
158
+
159
+
160
+ def format_number(value, decimals):
161
+ result = to_fixed(value, decimals)
162
+ return to_fixed(0, decimals) if float(result) == 0 else result
163
+
164
+
165
+ def default_decimals(*values):
166
+ return 0 if all(float(v).is_integer() for v in values) else 1
167
+
168
+
169
+ def fraction_of(value, minimum, maximum):
170
+ if (
171
+ not isinstance(value, (int, float))
172
+ or isinstance(value, bool)
173
+ or not math.isfinite(value)
174
+ ):
175
+ raise ValueError("Value must be finite")
176
+ return (
177
+ clamp((value - minimum) / (maximum - minimum), 0, 1) if maximum > minimum else 0
178
+ )
179
+
180
+
181
+ def require_range(what, minimum, maximum):
182
+ valid_numbers = all(
183
+ isinstance(value, (int, float))
184
+ and not isinstance(value, bool)
185
+ and math.isfinite(value)
186
+ for value in (minimum, maximum)
187
+ )
188
+ if not valid_numbers or maximum <= minimum:
189
+ raise ValueError(what + ": max must be greater than min")
190
+
191
+
192
+ def require_positive_integer(what, name, value):
193
+ if (
194
+ isinstance(value, bool)
195
+ or not isinstance(value, (int, float))
196
+ or not math.isfinite(value)
197
+ or int(value) != value
198
+ or value < 1
199
+ ):
200
+ raise ValueError(what + ": " + name + " must be a positive integer")
201
+
202
+
203
+ def label_layer(s):
204
+ style = dict(
205
+ color=s["color"],
206
+ textOpacity=s["opacity"],
207
+ fontSizeRatio=s["fontSizeRatio"],
208
+ horizontalAlignment=s["horizontal"],
209
+ verticalAlignment=s["vertical"],
210
+ )
211
+ if "fontWeight" in s:
212
+ style["fontWeight"] = s["fontWeight"]
213
+ return text(
214
+ dict(
215
+ id=s["id"],
216
+ frame=s["box"],
217
+ text=s["content"],
218
+ zIndex=s["zIndex"],
219
+ style=style,
220
+ )
221
+ )
222
+
223
+
224
+ def percent_label(fraction):
225
+ return str(js_round(fraction * 100)) + "%"
@@ -0,0 +1,95 @@
1
+ """Protocol colors use alpha first: #AARRGGBB."""
2
+
3
+ import math
4
+ import re
5
+
6
+
7
+ def js_round(value):
8
+ if not math.isfinite(value): # NaN/Infinity pass through like Math.round
9
+ return value
10
+ return math.floor(value + 0.5)
11
+
12
+
13
+ def js_div(a, b):
14
+ """a / b with IEEE results (Infinity/NaN) instead of ZeroDivisionError."""
15
+ if b == 0:
16
+ if a == 0 or a != a:
17
+ return math.nan
18
+ return math.copysign(math.inf, a) * math.copysign(1.0, b)
19
+ return a / b
20
+
21
+
22
+ def clamp(value, low, high):
23
+ if value != value: # NaN propagates like Math.min/Math.max
24
+ return value
25
+ return min(high, max(low, value))
26
+
27
+
28
+ JS_WHITESPACE = "\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680" \
29
+ "\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a" \
30
+ "\u2028\u2029\u202f\u205f\u3000\ufeff"
31
+
32
+
33
+ def parse_color(color):
34
+ if not isinstance(color, str):
35
+ raise ValueError("Invalid color: expected #RGB, #RRGGBB or #AARRGGBB")
36
+ color = color.strip(JS_WHITESPACE)
37
+ if not re.fullmatch(
38
+ r"#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})", color
39
+ ):
40
+ raise ValueError("Invalid color: expected #RGB, #RRGGBB or #AARRGGBB")
41
+ code = color.lstrip("#")
42
+ if len(code) == 3:
43
+ code = "".join(c * 2 for c in code)
44
+ if len(code) == 6:
45
+ code = "FF" + code
46
+ return dict(
47
+ zip(("a", "r", "g", "b"), (int(code[i : i + 2], 16) for i in range(0, 8, 2)))
48
+ )
49
+
50
+
51
+ def _js_number_text(v):
52
+ if isinstance(v, float) and v != v:
53
+ return "NaN"
54
+ if isinstance(v, float) and math.isinf(v):
55
+ return "Infinity" if v > 0 else "-Infinity"
56
+ return str(v)
57
+
58
+
59
+ def format_color(color):
60
+ def channel(v):
61
+ if (
62
+ isinstance(v, bool)
63
+ or not isinstance(v, (int, float))
64
+ or not math.isfinite(v)
65
+ ):
66
+ raise ValueError("Invalid color channel " + _js_number_text(v))
67
+ return clamp(js_round(v), 0, 255)
68
+
69
+ values = {k: channel(color[k]) for k in ("r", "g", "b", "a")}
70
+ code = "".join(f"{values[k]:02X}" for k in ("r", "g", "b"))
71
+ return "#" + (f"{values['a']:02X}" if values["a"] != 255 else "") + code
72
+
73
+
74
+ def normalize_color(color):
75
+ return format_color(parse_color(color))
76
+
77
+
78
+ def is_app_color(color):
79
+ return isinstance(color, str) and bool(
80
+ re.fullmatch(r"#?(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})", color)
81
+ )
82
+
83
+
84
+ def rgb(r, g, b, alpha=1):
85
+ return format_color(dict(r=r, g=g, b=b, a=alpha * 255))
86
+
87
+
88
+ def with_alpha(color, alpha):
89
+ return format_color({**parse_color(color), "a": alpha * 255})
90
+
91
+
92
+ def mix_colors(start, end, t):
93
+ a, b = parse_color(start), parse_color(end)
94
+ t = clamp(t, 0, 1)
95
+ return format_color({k: a[k] + (b[k] - a[k]) * t for k in a})