glance-widgets-sdk 1.0.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,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
glance_widgets/_opt.py ADDED
@@ -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})
@@ -0,0 +1,120 @@
1
+ {
2
+ "name": "3x5",
3
+ "height": 5,
4
+ "descent": 0,
5
+ "spacing": 1,
6
+ "fallback": "?",
7
+ "aliases": {
8
+ "a": "A",
9
+ "b": "B",
10
+ "c": "C",
11
+ "d": "D",
12
+ "e": "E",
13
+ "f": "F",
14
+ "g": "G",
15
+ "h": "H",
16
+ "i": "I",
17
+ "j": "J",
18
+ "k": "K",
19
+ "l": "L",
20
+ "m": "M",
21
+ "n": "N",
22
+ "o": "O",
23
+ "p": "P",
24
+ "q": "Q",
25
+ "r": "R",
26
+ "s": "S",
27
+ "t": "T",
28
+ "u": "U",
29
+ "v": "V",
30
+ "w": "W",
31
+ "x": "X",
32
+ "y": "Y",
33
+ "z": "Z"
34
+ },
35
+ "glyphs": {
36
+ "A": [".#.", "#.#", "###", "#.#", "#.#"],
37
+ "B": ["##.", "#.#", "##.", "#.#", "##."],
38
+ "C": [".##", "#..", "#..", "#..", ".##"],
39
+ "D": ["##.", "#.#", "#.#", "#.#", "##."],
40
+ "E": ["###", "#..", "##.", "#..", "###"],
41
+ "F": ["###", "#..", "##.", "#..", "#.."],
42
+ "G": [".##", "#..", "#.#", "#.#", ".##"],
43
+ "H": ["#.#", "#.#", "###", "#.#", "#.#"],
44
+ "I": ["###", ".#.", ".#.", ".#.", "###"],
45
+ "J": ["..#", "..#", "..#", "#.#", ".#."],
46
+ "K": ["#.#", "#.#", "##.", "#.#", "#.#"],
47
+ "L": ["#..", "#..", "#..", "#..", "###"],
48
+ "M": ["#.#", "###", "###", "#.#", "#.#"],
49
+ "N": ["##.", "#.#", "#.#", "#.#", "#.#"],
50
+ "O": [".#.", "#.#", "#.#", "#.#", ".#."],
51
+ "P": ["##.", "#.#", "##.", "#..", "#.."],
52
+ "Q": [".#.", "#.#", "#.#", "##.", ".##"],
53
+ "R": ["##.", "#.#", "##.", "#.#", "#.#"],
54
+ "S": [".##", "#..", ".#.", "..#", "##."],
55
+ "T": ["###", ".#.", ".#.", ".#.", ".#."],
56
+ "U": ["#.#", "#.#", "#.#", "#.#", "###"],
57
+ "V": ["#.#", "#.#", "#.#", "#.#", ".#."],
58
+ "W": ["#.#", "#.#", "###", "###", "#.#"],
59
+ "X": ["#.#", "#.#", ".#.", "#.#", "#.#"],
60
+ "Y": ["#.#", "#.#", ".#.", ".#.", ".#."],
61
+ "Z": ["###", "..#", ".#.", "#..", "###"],
62
+ "0": ["###", "#.#", "#.#", "#.#", "###"],
63
+ "1": [".#.", "##.", ".#.", ".#.", "###"],
64
+ "2": ["##.", "..#", ".#.", "#..", "###"],
65
+ "3": ["##.", "..#", ".#.", "..#", "##."],
66
+ "4": ["#.#", "#.#", "###", "..#", "..#"],
67
+ "5": ["###", "#..", "##.", "..#", "##."],
68
+ "6": [".##", "#..", "###", "#.#", "###"],
69
+ "7": ["###", "..#", ".#.", ".#.", ".#."],
70
+ "8": ["###", "#.#", "###", "#.#", "###"],
71
+ "9": ["###", "#.#", "###", "..#", "##."],
72
+ "!": ["#", "#", "#", ".", "#"],
73
+ "\"": ["#.#", "#.#", "...", "...", "..."],
74
+ "#": ["#.#", "###", "#.#", "###", "#.#"],
75
+ "$": [".#.", "###", "##.", ".##", "###"],
76
+ "%": ["#.#", "..#", ".#.", "#..", "#.#"],
77
+ "&": [".#.", "#.#", ".#.", "#.#", ".##"],
78
+ "'": ["#", "#", ".", ".", "."],
79
+ "(": [".#", "#.", "#.", "#.", ".#"],
80
+ ")": ["#.", ".#", ".#", ".#", "#."],
81
+ "*": ["...", "#.#", ".#.", "#.#", "..."],
82
+ "+": ["...", ".#.", "###", ".#.", "..."],
83
+ ",": ["..", "..", "..", ".#", "#."],
84
+ "-": ["...", "...", "###", "...", "..."],
85
+ ".": [".", ".", ".", ".", "#"],
86
+ "/": ["..#", "..#", ".#.", "#..", "#.."],
87
+ ":": [".", "#", ".", "#", "."],
88
+ ";": ["..", ".#", "..", ".#", "#."],
89
+ "<": ["..#", ".#.", "#..", ".#.", "..#"],
90
+ "=": ["...", "###", "...", "###", "..."],
91
+ ">": ["#..", ".#.", "..#", ".#.", "#.."],
92
+ "?": ["##.", "..#", ".#.", "...", ".#."],
93
+ "@": [".##", "#.#", "###", "#..", ".##"],
94
+ "[": ["##", "#.", "#.", "#.", "##"],
95
+ "\\": ["#..", "#..", ".#.", "..#", "..#"],
96
+ "]": ["##", ".#", ".#", ".#", "##"],
97
+ "^": [".#.", "#.#", "...", "...", "..."],
98
+ "_": ["...", "...", "...", "...", "###"],
99
+ "`": ["#.", ".#", "..", "..", ".."],
100
+ "{": [".##", ".#.", "##.", ".#.", ".##"],
101
+ "|": ["#", "#", "#", "#", "#"],
102
+ "}": ["##.", ".#.", ".##", ".#.", "##."],
103
+ "~": ["...", ".##", "##.", "...", "..."],
104
+ "°": [".#.", "#.#", ".#.", "...", "..."],
105
+ "±": [".#.", "###", ".#.", "...", "###"],
106
+ "×": ["...", "#.#", ".#.", "#.#", "..."],
107
+ "÷": [".#.", "...", "###", "...", ".#."],
108
+ "£": [".##", "#..", "##.", "#..", "###"],
109
+ "¥": ["#.#", "#.#", "###", ".#.", "###"],
110
+ "€": [".##", "#..", "###", "#..", ".##"],
111
+ "¢": [".#.", ".##", "#..", ".##", ".#."],
112
+ "•": ["..", "..", "##", "##", ".."],
113
+ "·": [".", ".", "#", ".", "."],
114
+ "←": [".#.", "#..", "###", "#..", ".#."],
115
+ "→": [".#.", "..#", "###", "..#", ".#."],
116
+ "↑": [".#.", "###", ".#.", ".#.", ".#."],
117
+ "↓": [".#.", ".#.", ".#.", "###", ".#."],
118
+ " ": {"rows": [], "advance": 2}
119
+ }
120
+ }