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.
- glance_widgets_sdk-1.0.0/LICENSE +17 -0
- glance_widgets_sdk-1.0.0/MANIFEST.in +2 -0
- glance_widgets_sdk-1.0.0/PKG-INFO +56 -0
- glance_widgets_sdk-1.0.0/README.md +44 -0
- glance_widgets_sdk-1.0.0/pyproject.toml +26 -0
- glance_widgets_sdk-1.0.0/setup.cfg +4 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/__init__.py +138 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/_opt.py +6 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/_shared.py +225 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/color.py +95 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/data/fonts/font-3x5.json +120 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/data/fonts/font-5x7.json +118 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/data/icons.json +5293 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/data/schemas.json +418 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/data/text-advances.json +120 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/font.py +191 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/geometry.py +293 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/heatmap.py +323 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/icons.py +24 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/layers.py +94 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/line_chart.py +431 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/matrix.py +332 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/modern.py +722 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/output.py +120 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/pixel_bar_chart.py +222 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/pixel_progress.py +274 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/pixel_text.py +129 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/pixelate.py +64 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/protocol.py +255 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/raster.py +174 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/signature.py +65 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/stat.py +324 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/status.py +204 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/text_matrix.py +13 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets/themes.py +55 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets_sdk.egg-info/PKG-INFO +56 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets_sdk.egg-info/SOURCES.txt +38 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets_sdk.egg-info/dependency_links.txt +1 -0
- glance_widgets_sdk-1.0.0/src/glance_widgets_sdk.egg-info/requires.txt +3 -0
- 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,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,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,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})
|