scrollkit 0.8.3__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.
Files changed (111) hide show
  1. scrollkit/__init__.py +16 -0
  2. scrollkit/app/__init__.py +6 -0
  3. scrollkit/app/base.py +918 -0
  4. scrollkit/app/memory.py +70 -0
  5. scrollkit/config/__init__.py +1 -0
  6. scrollkit/config/settings_manager.py +215 -0
  7. scrollkit/config/transition_names.py +31 -0
  8. scrollkit/dev/__init__.py +41 -0
  9. scrollkit/dev/capabilities.py +374 -0
  10. scrollkit/dev/harness.py +383 -0
  11. scrollkit/dev/metrics.py +91 -0
  12. scrollkit/dev/performance.py +174 -0
  13. scrollkit/dev/validation.py +245 -0
  14. scrollkit/display/__init__.py +14 -0
  15. scrollkit/display/_graphics.py +299 -0
  16. scrollkit/display/_recording.py +165 -0
  17. scrollkit/display/_sim_backend.py +99 -0
  18. scrollkit/display/bitmap_text.py +408 -0
  19. scrollkit/display/boards.py +186 -0
  20. scrollkit/display/colors.py +176 -0
  21. scrollkit/display/content.py +604 -0
  22. scrollkit/display/gradient_text.py +167 -0
  23. scrollkit/display/interface.py +126 -0
  24. scrollkit/display/simulator.py +80 -0
  25. scrollkit/display/text_fill.py +59 -0
  26. scrollkit/display/text_pixels.py +250 -0
  27. scrollkit/display/unified.py +595 -0
  28. scrollkit/effects/__init__.py +28 -0
  29. scrollkit/effects/drip_splash.py +253 -0
  30. scrollkit/effects/easing.py +131 -0
  31. scrollkit/effects/overlay.py +92 -0
  32. scrollkit/effects/particles.py +355 -0
  33. scrollkit/effects/reveal_splash.py +132 -0
  34. scrollkit/effects/scrolling.py +363 -0
  35. scrollkit/effects/swarm_reveal.py +512 -0
  36. scrollkit/effects/text_render.py +25 -0
  37. scrollkit/effects/transitions.py +873 -0
  38. scrollkit/exceptions.py +55 -0
  39. scrollkit/network/__init__.py +1 -0
  40. scrollkit/network/http_client.py +505 -0
  41. scrollkit/network/mdns.py +44 -0
  42. scrollkit/network/wifi_manager.py +382 -0
  43. scrollkit/ota/__init__.py +13 -0
  44. scrollkit/ota/client.py +528 -0
  45. scrollkit/ota/display_progress.py +125 -0
  46. scrollkit/ota/manifest.py +206 -0
  47. scrollkit/ota/publish.py +379 -0
  48. scrollkit/simulator/ATTRIBUTION.md +20 -0
  49. scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
  50. scrollkit/simulator/LICENSE +176 -0
  51. scrollkit/simulator/README.md +87 -0
  52. scrollkit/simulator/__init__.py +11 -0
  53. scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
  54. scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
  55. scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
  56. scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
  57. scrollkit/simulator/adafruit_display_text/label.py +336 -0
  58. scrollkit/simulator/bitmaptools.py +50 -0
  59. scrollkit/simulator/core/__init__.py +8 -0
  60. scrollkit/simulator/core/color_utils.py +62 -0
  61. scrollkit/simulator/core/device_benchmarks.json +254 -0
  62. scrollkit/simulator/core/feasibility.py +160 -0
  63. scrollkit/simulator/core/hardware_profile.py +191 -0
  64. scrollkit/simulator/core/led_matrix.py +307 -0
  65. scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
  66. scrollkit/simulator/core/performance_manager.py +253 -0
  67. scrollkit/simulator/core/pixel_buffer.py +188 -0
  68. scrollkit/simulator/devices/__init__.py +7 -0
  69. scrollkit/simulator/devices/base_device.py +79 -0
  70. scrollkit/simulator/devices/matrixportal_s3.py +87 -0
  71. scrollkit/simulator/displayio/__init__.py +12 -0
  72. scrollkit/simulator/displayio/bitmap.py +158 -0
  73. scrollkit/simulator/displayio/display.py +195 -0
  74. scrollkit/simulator/displayio/fourwire.py +54 -0
  75. scrollkit/simulator/displayio/group.py +125 -0
  76. scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
  77. scrollkit/simulator/displayio/palette.py +115 -0
  78. scrollkit/simulator/displayio/tilegrid.py +155 -0
  79. scrollkit/simulator/fonts/3x5.bdf +2474 -0
  80. scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
  81. scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
  82. scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
  83. scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
  84. scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
  85. scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
  86. scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
  87. scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
  88. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
  89. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
  90. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
  91. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
  92. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
  93. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
  94. scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
  95. scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
  96. scrollkit/simulator/fonts/viii.bdf +2659 -0
  97. scrollkit/simulator/terminalio/__init__.py +20 -0
  98. scrollkit/utils/__init__.py +1 -0
  99. scrollkit/utils/color_utils.py +54 -0
  100. scrollkit/utils/diagnostics.py +227 -0
  101. scrollkit/utils/error_handler.py +347 -0
  102. scrollkit/utils/system_utils.py +245 -0
  103. scrollkit/utils/url_utils.py +46 -0
  104. scrollkit/web/__init__.py +6 -0
  105. scrollkit/web/settings_server.py +328 -0
  106. scrollkit/web/wifi_setup.py +331 -0
  107. scrollkit-0.8.3.dist-info/METADATA +248 -0
  108. scrollkit-0.8.3.dist-info/RECORD +111 -0
  109. scrollkit-0.8.3.dist-info/WHEEL +5 -0
  110. scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
  111. scrollkit-0.8.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,70 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Realistic free-RAM estimate shared by the run loop and the dev harness.
3
+
4
+ The run loop gates optional processes on free RAM (data updates need ~20-30 KB,
5
+ the web server ~50 KB). On real CircuitPython that comes from ``gc.mem_free()``.
6
+ On the desktop simulator there is no such limit, so historically these sites just
7
+ pretended ~100 KB was free — which means an AI agent verifying an app on the
8
+ simulator never sees the low-memory code paths a real MatrixPortal S3 would hit.
9
+
10
+ ``free_memory()`` keeps that generous desktop default UNLESS the hardware-realism
11
+ simulation is active (``SimulatorDisplay(hardware_timing=True)`` /
12
+ ``SCROLLKIT_HW_SIM=1``), in which case it reports the modeled *device* budget
13
+ minus the modeled live allocation. So the existing 20/30/50 KB ladder actually
14
+ gates against the device's tiny RAM during a simulated run, and the feasibility
15
+ report's "won't fit" warning lines up with what the loop decided.
16
+
17
+ Default behavior is unchanged: on hardware it's the real number; on desktop with
18
+ the sim OFF it's the same large constant as before.
19
+
20
+ Device-safe: ``gc`` is the only hard import; the simulator lookup is wrapped so it
21
+ never fails on CircuitPython (where the simulator package isn't present).
22
+ """
23
+
24
+ import gc
25
+
26
+ # Free RAM reported on desktop when the hardware sim is OFF. Matches the literal
27
+ # previously hard-coded across app/base.py, so default behavior is unchanged.
28
+ DESKTOP_FREE_BYTES = 100_000
29
+
30
+
31
+ __all__ = ['free_memory', 'DESKTOP_FREE_BYTES']
32
+
33
+ def free_memory():
34
+ """Best estimate of free RAM in bytes.
35
+
36
+ - CircuitPython hardware: the real ``gc.mem_free()``.
37
+ - Desktop + hardware sim ON: modeled device budget minus modeled usage.
38
+ - Desktop + hardware sim OFF: ``DESKTOP_FREE_BYTES`` (no behavior change).
39
+ """
40
+ if hasattr(gc, "mem_free"):
41
+ # Real hardware (or any build exposing mem_free) — trust the device.
42
+ try:
43
+ return gc.mem_free()
44
+ except Exception:
45
+ pass
46
+ modeled = _modeled_free_memory()
47
+ if modeled is not None:
48
+ return modeled
49
+ return DESKTOP_FREE_BYTES
50
+
51
+
52
+ def _modeled_free_memory():
53
+ """Free RAM implied by the active hardware PerformanceManager, or None.
54
+
55
+ The import lives entirely inside the (desktop-only) simulator package; any
56
+ failure simply means "no hardware model active" and we fall back.
57
+ """
58
+ try:
59
+ from ..simulator.core.performance_manager import get_active
60
+ except Exception:
61
+ return None
62
+ manager = get_active()
63
+ if manager is None:
64
+ return None
65
+ try:
66
+ budget = manager.profile.usable_ram_bytes
67
+ used = manager.estimated_peak_ram_bytes()
68
+ return max(0, budget - used)
69
+ except Exception:
70
+ return None
@@ -0,0 +1 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
@@ -0,0 +1,215 @@
1
+ """
2
+ Settings manager for handling user configuration.
3
+ Copyright (c) 2024-2026 Michael Winslow Czeiszperger
4
+ """
5
+ import json
6
+
7
+ from scrollkit.config.transition_names import TRANSITION_NAMES
8
+
9
+
10
+ __all__ = ['SettingsManager']
11
+
12
+ def _logger():
13
+ # Lazy: constructing ErrorHandler does a filesystem write-test, so it must
14
+ # not run merely from importing this module. Its own __new__ singleton
15
+ # guard makes repeat calls cheap.
16
+ from scrollkit.utils.error_handler import ErrorHandler
17
+ return ErrorHandler("error_log")
18
+
19
+
20
+ class SettingsManager:
21
+ """
22
+ Manages application settings with persistence to a JSON file.
23
+
24
+ Applications can register their own defaults by calling
25
+ set_defaults({'key': value, ...}) after construction.
26
+ """
27
+
28
+ def __init__(self, filename, defaults=None, bool_keys=None):
29
+ """
30
+ Initialize the settings manager.
31
+
32
+ Args:
33
+ filename: The name of the settings file
34
+ defaults: Optional dict of default settings
35
+ bool_keys: Optional list of keys that should be treated as booleans
36
+ (needed because CircuitPython's JSON parser may store them as strings)
37
+ """
38
+ self.filename = filename
39
+ self.settings = self.load_settings()
40
+ self.scroll_speed = {"Slow": 0.06, "Medium": 0.04, "Fast": 0.02}
41
+ self._bool_keys = bool_keys or []
42
+ self._schema = []
43
+
44
+ # Built-in library settings — always defined so the default web UI shows them.
45
+ self.define("brightness_scale", 0.5, label="Brightness", min=0.0, max=1.0, step=0.05)
46
+ self.define("scroll_speed", "Medium", label="Scroll Speed",
47
+ options=["None", "Slow", "Medium", "Fast"])
48
+ self.define("default_color", 0xFFFFFF, label="Default Color", type="color")
49
+ # Choices derive from the single source of truth (config.transition_names),
50
+ # which the dispatch map in effects.transitions is tested to match — so a
51
+ # selectable name can never silently fail to dispatch. Plain "None" + list
52
+ # concatenation (no *-unpacking) for CircuitPython-parser safety.
53
+ self.define("transition_style", "None", label="Transition Style",
54
+ options=["None"] + list(TRANSITION_NAMES))
55
+
56
+ # Apply application-provided defaults
57
+ if defaults:
58
+ self.set_defaults(defaults)
59
+
60
+ def define(self, key, default, label=None, type=None, options=None,
61
+ min=None, max=None, step=None):
62
+ """Declare a setting with display metadata for the auto-generating web UI.
63
+
64
+ The UI renders a form field for every defined setting in the order they
65
+ were declared. Type is inferred from the default value when not given:
66
+ bool -> checkbox, options list -> select, min/max -> range,
67
+ int/float -> number, else text. Use type="color" explicitly for
68
+ colour pickers (stored as int 0xRRGGBB).
69
+
70
+ Args:
71
+ key: Settings key (used as the form field name)
72
+ default: Default value (only applied when no saved value exists)
73
+ label: Human-readable label; defaults to title-cased key name
74
+ type: Field type override ("text","number","range","color","select","checkbox")
75
+ options: List of string choices (implies type="select")
76
+ min: Numeric lower bound for range/number inputs
77
+ max: Numeric upper bound for range/number inputs
78
+ step: Numeric step for range/number inputs
79
+ """
80
+ if type is not None:
81
+ resolved_type = type
82
+ elif options:
83
+ resolved_type = "select"
84
+ elif min is not None or max is not None:
85
+ resolved_type = "range"
86
+ elif isinstance(default, bool): # must check before int (bool subclasses int)
87
+ resolved_type = "checkbox"
88
+ elif isinstance(default, (int, float)):
89
+ resolved_type = "number"
90
+ else:
91
+ resolved_type = "text"
92
+
93
+ resolved_label = label if label is not None else SettingsManager.get_pretty_name(key)
94
+
95
+ self._schema.append({
96
+ "key": key,
97
+ "label": resolved_label,
98
+ "type": resolved_type,
99
+ "default": default,
100
+ "options": options,
101
+ "min": min,
102
+ "max": max,
103
+ "step": step,
104
+ })
105
+
106
+ if resolved_type == "checkbox":
107
+ self.add_bool_keys(key)
108
+
109
+ self.set_defaults({key: default})
110
+
111
+ def set_defaults(self, defaults):
112
+ """
113
+ Register application-specific defaults. Only sets values that
114
+ are not already present in settings.
115
+
116
+ Args:
117
+ defaults: Dict of {key: default_value}
118
+ """
119
+ for key, value in defaults.items():
120
+ if key not in self.settings:
121
+ self.settings[key] = value
122
+
123
+ def add_bool_keys(self, *keys):
124
+ """Register additional keys that should be treated as booleans."""
125
+ for key in keys:
126
+ if key not in self._bool_keys:
127
+ self._bool_keys.append(key)
128
+
129
+ def get_scroll_speed(self):
130
+ """Return scroll speed in seconds per pixel (for timing loops)."""
131
+ return self.scroll_speed.get(
132
+ self.settings.get("scroll_speed", "Medium"), 0.04
133
+ )
134
+
135
+ def get_scroll_speed_px(self):
136
+ """Return scroll speed in pixels per second (for ScrollingText speed= arg).
137
+
138
+ Returns 0 when scroll_speed is "None" — ScrollingText treats 0 as
139
+ static-display mode: text is shown centred for a fixed duration rather
140
+ than scrolling.
141
+ """
142
+ if self.settings.get("scroll_speed", "Medium") == "None":
143
+ return 0
144
+ secs = self.get_scroll_speed()
145
+ return int(round(1.0 / secs)) if secs > 0 else 25
146
+
147
+ @staticmethod
148
+ def get_pretty_name(settings_name):
149
+ """
150
+ Convert a settings key to a display-friendly name.
151
+
152
+ Args:
153
+ settings_name: The settings key
154
+
155
+ Returns:
156
+ A display-friendly name
157
+ """
158
+ new_name = settings_name.replace("_", " ")
159
+ return " ".join(word[0].upper() + word[1:] for word in new_name.split(' '))
160
+
161
+ def load_settings(self):
162
+ """
163
+ Load settings from the settings file.
164
+
165
+ Returns:
166
+ A dictionary of settings
167
+ """
168
+ _logger().info(f"Loading settings {self.filename}")
169
+ try:
170
+ with open(self.filename, 'r') as f:
171
+ return json.load(f)
172
+ except (OSError, ValueError):
173
+ # OSError: missing file. ValueError: corrupt JSON — CircuitPython's
174
+ # json.load raises ValueError (there is no JSONDecodeError), and a
175
+ # truncated flash write must not brick boot.
176
+ return {}
177
+
178
+ def save_settings(self):
179
+ """Save settings to the settings file"""
180
+ _logger().info(f"Saving settings {self.filename}")
181
+ try:
182
+ with open(self.filename, 'w') as f:
183
+ json.dump(self.settings, f)
184
+ except OSError as e:
185
+ _logger().error(e, f"Error saving settings to {self.filename}")
186
+
187
+ def get(self, key, default=None):
188
+ """
189
+ Get a setting by key with a default value.
190
+
191
+ Args:
192
+ key: The settings key
193
+ default: The default value if the key is not found
194
+
195
+ Returns:
196
+ The setting value, or the default if not found
197
+ """
198
+ value = self.settings.get(key, default)
199
+
200
+ # Special handling for boolean settings that might be stored as strings
201
+ # This can happen with CircuitPython's JSON parser
202
+ if key in self._bool_keys and isinstance(value, str):
203
+ return value.lower() == "true"
204
+
205
+ return value
206
+
207
+ def set(self, key, value):
208
+ """
209
+ Set a setting by key.
210
+
211
+ Args:
212
+ key: The settings key
213
+ value: The value to set
214
+ """
215
+ self.settings[key] = value
@@ -0,0 +1,31 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Canonical list of built-in transition names — the single source of truth.
3
+
4
+ This module is **literal-only**: it imports nothing, so it is safe to import on
5
+ the device boot path (e.g. from ``settings_manager``) without dragging in the
6
+ heavy ``scrollkit.effects`` package. The name -> class dispatch lives in
7
+ ``scrollkit.effects.transitions`` (``_TRANSITION_MAP`` / ``transition_factory``);
8
+ ``test_transition_registry`` asserts the two stay in lockstep, in this order.
9
+
10
+ Order is UI-facing: the settings dropdown / web form render the choices in this
11
+ order (with ``"None"`` prepended), so do not reorder casually.
12
+ """
13
+
14
+ TRANSITION_NAMES = (
15
+ "Drop from Sky",
16
+ "Pixel Dissolve",
17
+ "Column Rain",
18
+ "Gradual Reveal",
19
+ "Scan Fold",
20
+ "Horizontal Wipe",
21
+ "Glitch Bars",
22
+ "Diagonal Wipe",
23
+ "Iris Snap",
24
+ "Venetian Shutters",
25
+ "Mosaic Resolve",
26
+ "CRT Collapse",
27
+ "Light Slit",
28
+ )
29
+
30
+
31
+ __all__ = ['TRANSITION_NAMES']
@@ -0,0 +1,41 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Desktop-only developer / AI-agent affordances for ScrollKit.
3
+
4
+ Everything here exists to help a human or an AI agent build and *verify* a
5
+ ScrollKit app on the desktop simulator before flashing it to real hardware:
6
+ a headless run harness, pixel metrics, and (via the simulator package) an honest
7
+ estimate of how the app would perform on the slow, RAM-constrained MatrixPortal
8
+ S3.
9
+
10
+ This subpackage is **never** meant to run on the device. It pulls in numpy,
11
+ pygame, and other desktop-only machinery that would waste scarce RAM (or simply
12
+ not import) on CircuitPython. Importing it there raises ImportError immediately,
13
+ on purpose, so device code can't accidentally depend on it. Nothing in
14
+ ``scrollkit`` core (``app/``, ``display/``, the top-level ``__init__``) imports
15
+ this module.
16
+ """
17
+
18
+ import sys
19
+
20
+ # Hard stop on CircuitPython — these tools are desktop-only by design.
21
+ if getattr(sys, "implementation", None) is not None \
22
+ and getattr(sys.implementation, "name", None) == "circuitpython":
23
+ raise ImportError(
24
+ "scrollkit.dev is a desktop-only development/verification toolkit and "
25
+ "cannot run on CircuitPython. Use it from the desktop simulator to "
26
+ "build and check apps before deploying to the device."
27
+ )
28
+
29
+ from .harness import ( # noqa: E402
30
+ RunResult, run_headless, run_headless_async, record_gif, record_video)
31
+ from .capabilities import capabilities, as_text # noqa: E402
32
+ from .validation import validate, ValidationReport, Issue # noqa: E402
33
+ from .performance import performance_guide, performance_text # noqa: E402
34
+ from . import metrics # noqa: E402,F401
35
+
36
+ __all__ = [
37
+ "RunResult", "run_headless", "run_headless_async", "record_gif",
38
+ "record_video", "metrics",
39
+ "capabilities", "as_text", "validate", "ValidationReport", "Issue",
40
+ "performance_guide", "performance_text",
41
+ ]