diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 7b4cb0ac..375a70fb 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -72,7 +72,7 @@ jobs: - run: | mk python-release owner=vkottler \ - repo=runtimepy version=4.5.0 + repo=runtimepy version=4.5.1 if: | matrix.python-version == '3.11' && matrix.system == 'ubuntu-latest' diff --git a/README.md b/README.md index 421fe625..31d54fa1 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ===================================== generator=datazen version=3.1.4 - hash=48a3836c8cab67b925fe019f3db34c8d + hash=c9e8e0a4b1765408663652c02a32e75e ===================================== --> -# runtimepy ([4.5.0](https://pypi.org/project/runtimepy/)) +# runtimepy ([4.5.1](https://pypi.org/project/runtimepy/)) [![python](https://img.shields.io/pypi/pyversions/runtimepy.svg)](https://pypi.org/project/runtimepy/) ![Build Status](https://github.com/vkottler/runtimepy/workflows/Python%20Package/badge.svg) diff --git a/local/variables/package.yaml b/local/variables/package.yaml index 175b2006..715f03b9 100644 --- a/local/variables/package.yaml +++ b/local/variables/package.yaml @@ -1,5 +1,5 @@ --- major: 4 minor: 5 -patch: 0 +patch: 1 entry: runtimepy diff --git a/pyproject.toml b/pyproject.toml index 276af13c..58896b7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta:__legacy__" [project] name = "runtimepy" -version = "4.5.0" +version = "4.5.1" description = "A framework for implementing Python services." readme = "README.md" requires-python = ">=3.11" diff --git a/runtimepy/__init__.py b/runtimepy/__init__.py index b8e99d1f..bd426d42 100644 --- a/runtimepy/__init__.py +++ b/runtimepy/__init__.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.4 -# hash=9539398893e7a420adf97d118b6bcabf +# hash=4c10d97db8d952d1b242acfcfd80abb4 # ===================================== """ @@ -10,7 +10,7 @@ DESCRIPTION = "A framework for implementing Python services." PKG_NAME = "runtimepy" -VERSION = "4.5.0" +VERSION = "4.5.1" # runtimepy-specific content. METRICS_NAME = "metrics" diff --git a/runtimepy/channel/environment/base.py b/runtimepy/channel/environment/base.py index a92b1102..2ac6f5b5 100644 --- a/runtimepy/channel/environment/base.py +++ b/runtimepy/channel/environment/base.py @@ -106,13 +106,15 @@ def __setitem__(self, key: _RegistryKey, value: ChannelValue) -> None: """Mapping-set interface.""" return self.set(key, value) - def set(self, key: _RegistryKey, value: ChannelValue) -> None: + def set( + self, key: _RegistryKey, value: ChannelValue, scaled: bool = True + ) -> None: """Attempt to set an arbitrary channel value.""" # Set a field value if this key maps to a bit-field. if self.fields.has_field(key): assert not isinstance(value, float) - self.fields.set(key, value) + self.fields.set(key, value, scaled=scaled) return chan, enum = self[key] @@ -155,7 +157,10 @@ def set(self, key: _RegistryKey, value: ChannelValue) -> None: ) # Assign the value to the channel. - chan.raw.scaled = value # type: ignore + if scaled: + chan.raw.scaled = value # type: ignore + else: + chan.raw.value = value # type: ignore def apply(self, values: ValueMap) -> None: """Apply a map of values to the environment.""" @@ -172,17 +177,19 @@ def values(self, resolve_enum: bool = True) -> ValueMap: } def value( - self, key: _RegistryKey, resolve_enum: bool = True + self, key: _RegistryKey, resolve_enum: bool = True, scaled: bool = True ) -> ChannelValue: """Attempt to get a channel's current value.""" # Get the value from a field if this key points to a bit-field. if self.fields.has_field(key): - return self.fields.get(key, resolve_enum=resolve_enum) + return self.fields.get( + key, resolve_enum=resolve_enum, scaled=scaled + ) chan, enum = self[key] - value: ChannelValue = chan.raw.scaled + value: ChannelValue = chan.raw.scaled if scaled else chan.raw.value # Resolve enumeration values to strings. if enum is not None and resolve_enum: diff --git a/runtimepy/data/js/classes/Plot.js b/runtimepy/data/js/classes/Plot.js index 243b54a6..81f326c5 100644 --- a/runtimepy/data/js/classes/Plot.js +++ b/runtimepy/data/js/classes/Plot.js @@ -17,9 +17,12 @@ class Plot { let plotButton = document.getElementById("runtimepy-plot-button"); if (plotButton) { this.canvas.onclick = (event) => { plotButton.click(); }; + this.canvas.onwheel = this.onWheel.bind(this); } } + onWheel(event) { this.plotMessage({"wheelDelta" : event.wheelDelta}); } + plotMessage(data, param) { this.worker.toWorker({"plot" : data}, param); } messageBase() { diff --git a/runtimepy/data/js/classes/PlotDrawer.js b/runtimepy/data/js/classes/PlotDrawer.js index 45049c0d..657d034e 100644 --- a/runtimepy/data/js/classes/PlotDrawer.js +++ b/runtimepy/data/js/classes/PlotDrawer.js @@ -109,4 +109,15 @@ class PlotDrawer { this.wglp.viewport(0, 0, this.canvas.width, this.canvas.height); } + + updateDepth(wheelDelta) { + for (let name in this.channels) { + let chan = this.channels[name]; + + /* Make configurable at some point? */ + chan.buffer.bumpCapacity(wheelDelta > 0); + + chan.draw(this.lines[name]); + } + } } diff --git a/runtimepy/data/js/classes/PlotManager.js b/runtimepy/data/js/classes/PlotManager.js index 0b87e8f9..1dfe5e73 100644 --- a/runtimepy/data/js/classes/PlotManager.js +++ b/runtimepy/data/js/classes/PlotManager.js @@ -48,6 +48,12 @@ class PlotManager { } } + updateDepth(name, wheelDelta) { + if (name in this.drawers) { + this.drawers[name].updateDepth(wheelDelta); + } + } + async handleMessage(data) { let name = data["name"]; @@ -56,6 +62,11 @@ class PlotManager { this.plots[name] = data["canvas"]; } + /* Handle scroll events. */ + if ("wheelDelta" in data) { + this.updateDepth(name, data["wheelDelta"]); + } + /* Handle size updates. */ if ("width" in data && "height" in data) { let canvas = this.plots[name]; diff --git a/runtimepy/data/js/classes/PointBuffer.js b/runtimepy/data/js/classes/PointBuffer.js index 45acff14..e8dfe9dd 100644 --- a/runtimepy/data/js/classes/PointBuffer.js +++ b/runtimepy/data/js/classes/PointBuffer.js @@ -4,9 +4,20 @@ class PointBuffer { this.values = []; this.timestamps = []; + this.elements = 0; this.updateCapacity(capacity); } + bumpCapacity(bumpUp) { + /* + * need persistent settings for scroll behavior? (configurable?) + */ + let scale_factor = 1.25; + let newCapacity = Math.max(16, bumpUp ? this.capacity * scale_factor + : this.capacity / scale_factor); + this.updateCapacity(Math.round(newCapacity)); + } + reset() { this.head = 0; this.tail = 0; @@ -16,20 +27,32 @@ class PointBuffer { } updateCapacity(capacity) { - this.capacity = capacity; - this.reset(); + /* Copy existing values. */ + let points = []; - let newValues = new Array(this.capacity); - let newTimestamps = new Array(this.capacity); + let count = Math.min(this.elements, capacity, this.capacity); + if (count > 0) { + let startIdx = this.head; - /* Copy existing values. */ - for (let i = this.values.length; i < this.capacity; i++) { - newValues[i] = this.values[i]; - newTimestamps[i] = this.timestamps[i]; + /* If the buffer is getting smaller, advance the buffer index forward. */ + if (count < this.elements) { + startIdx += this.elements - count; + startIdx = startIdx % this.capacity; + } + + for (let i = 0; i < count - 1; i++) { + let idx = (startIdx + i) % this.capacity; + points.push([ this.values[idx], this.timestamps[idx] ]); + } } - this.values = newValues; - this.timestamps = newTimestamps; + /* Reset state and re-ingest points. */ + this.reset(); + this.capacity = capacity; + this.values = new Array(this.capacity); + this.timestamps = new Array(this.capacity); + + this.ingest(points); } ingest(points) { @@ -62,15 +85,29 @@ class PointBuffer { */ let slope = 2 / (newestTimestamp - oldestTimestamp); - - /* Build array of plot-able timestamp X values. */ let times = []; - let idx = oldestIdx; - while (idx != newestIdx) { + + if (slope > 0) { + /* Build array of plot-able timestamp X values. */ + let idx = oldestIdx; + + while (idx != newestIdx) { + times.push(((this.timestamps[idx] - oldestTimestamp) * slope) - 1); + idx = this.incrIndex(idx); + } times.push(((this.timestamps[idx] - oldestTimestamp) * slope) - 1); - idx = this.incrIndex(idx); + + } else { + /* need to root-cause this off-by-one issue */ + console.log(`${newestIdx}, ${oldestIdx}, ${this.elements}`); + console.log(slope); + + let idx = oldestIdx; + while (idx != newestIdx) { + times.push(oldestTimestamp) + idx = this.incrIndex(idx); + } } - times.push(((this.timestamps[idx] - oldestTimestamp) * slope) - 1); return times; } diff --git a/runtimepy/primitives/base.py b/runtimepy/primitives/base.py index a771a104..f7173714 100644 --- a/runtimepy/primitives/base.py +++ b/runtimepy/primitives/base.py @@ -3,11 +3,13 @@ """ # built-in +from contextlib import contextmanager as _contextmanager from copy import copy as _copy from math import isclose as _isclose from typing import BinaryIO as _BinaryIO from typing import Callable as _Callable from typing import Generic as _Generic +from typing import Iterator as _Iterator from typing import TypeVar as _TypeVar # third-party @@ -104,6 +106,18 @@ def remove_callback(self, callback_id: int) -> bool: del self.callbacks[callback_id] return result + @_contextmanager + def callback( + self, callback: PrimitiveChangeCallaback[T] + ) -> _Iterator[None]: + """Register a callback as a managed context.""" + + ident = self.register_callback(callback) + try: + yield + finally: + self.remove_callback(ident) + @property def value(self) -> T: """Obtain the underlying value.""" diff --git a/runtimepy/primitives/field/manager/base.py b/runtimepy/primitives/field/manager/base.py index 79b784fb..a75e52e6 100644 --- a/runtimepy/primitives/field/manager/base.py +++ b/runtimepy/primitives/field/manager/base.py @@ -108,9 +108,17 @@ def add(self, fields: _BitFields) -> int: return index - def set(self, key: _RegistryKey, value: _Union[int, bool, str]) -> None: + def set( + self, + key: _RegistryKey, + value: _Union[int, bool, str], + scaled: bool = True, + ) -> None: """Set a value of a field.""" + # Bit fields don't support scaling. + del scaled + field = self[key] if isinstance(value, str): @@ -125,10 +133,13 @@ def set(self, key: _RegistryKey, value: _Union[int, bool, str]) -> None: field(int(value)) def get( - self, key: _RegistryKey, resolve_enum: bool = True + self, key: _RegistryKey, resolve_enum: bool = True, scaled: bool = True ) -> _Union[int, bool, str]: """Get the value of a field.""" + # Bit fields don't support scaling. + del scaled + field = self[key] value: _Union[int, str] = field() diff --git a/tests/channel/environment/test_environment.py b/tests/channel/environment/test_environment.py index 5838d822..4bd78aeb 100644 --- a/tests/channel/environment/test_environment.py +++ b/tests/channel/environment/test_environment.py @@ -173,3 +173,5 @@ def test_channel_environment_basic(): assert env.get_int(4) is not None verify_missing_keys(env) + + env.set("float.1", 1.0, scaled=False) diff --git a/tests/data/valid/connection_arbiter/runtimepy_http.yaml b/tests/data/valid/connection_arbiter/runtimepy_http.yaml index 7b7006f5..6a98840a 100644 --- a/tests/data/valid/connection_arbiter/runtimepy_http.yaml +++ b/tests/data/valid/connection_arbiter/runtimepy_http.yaml @@ -8,6 +8,7 @@ app: config: experimental: true foo: bar + xdg_fragment: "wave1,hide-tabs,hide-channels/wave1:sin,cos" clients: - factory: runtimepy_http diff --git a/tests/primitives/test_bool.py b/tests/primitives/test_bool.py index 34180d3f..de1be5d7 100644 --- a/tests/primitives/test_bool.py +++ b/tests/primitives/test_bool.py @@ -17,3 +17,19 @@ def test_bool_basic(): prim.clear() assert copied() + + call_count = 0 + + def change_cb(_: bool, __: bool) -> None: + """A sample callback.""" + nonlocal call_count + call_count += 1 + + with prim.callback(change_cb): + prim.toggle() + prim.toggle() + + prim.toggle() + prim.toggle() + + assert call_count == 2