step 6: add animation and high rate update stress tests
This commit is contained in:
@@ -2,10 +2,11 @@
|
||||
|
||||
Dear PyGui gauge widgets for dashboards and telemetry displays.
|
||||
|
||||
Current implementation status: Step 5 first catalogue rendering is implemented. Analog, digital,
|
||||
bar, level, status, segmented, compass, thermometer, battery, and multi-value gauges render Dear
|
||||
PyGui drawlist visuals. Bar/level gauges draw threshold bands and markers; analog gauges draw ticks,
|
||||
Current implementation status: Step 6 high-rate update support is implemented. Analog, digital, bar,
|
||||
level, status, segmented, compass, thermometer, battery, and multi-value gauges render Dear PyGui
|
||||
drawlist visuals. Bar/level gauges draw threshold bands and markers; analog gauges draw ticks,
|
||||
redline bands, markers, and needles.
|
||||
|
||||
Runtime value updates are intended for the Dear PyGui GUI thread. Attached gauges redraw immediately
|
||||
for simple value/configuration changes.
|
||||
for simple value/configuration changes. Animation and smoothing are configurable per gauge; high-rate
|
||||
examples marshal latest producer-thread values to GUI-thread frame callbacks.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Current status
|
||||
|
||||
Step 5 completed and verified with automated checks. Step 5 commit is pending.
|
||||
Step 6 completed and verified with automated checks. Step 6 commit is pending.
|
||||
|
||||
## Completed steps
|
||||
|
||||
@@ -28,7 +28,19 @@ Step 1 - Public API contract and pure core:
|
||||
|
||||
## Current step
|
||||
|
||||
Step 6 - High-rate updates, animation, and stress examples.
|
||||
Step 7 - Documentation, hardening, and beta release.
|
||||
|
||||
Step 6 - High-rate updates, animation, and stress examples:
|
||||
|
||||
Implemented during Step 6:
|
||||
|
||||
- Runtime updates now start animations using monotonic time for public calls.
|
||||
- Optional smoothing now progresses displayed values toward the latest target instead of snapping immediately.
|
||||
- Repeated GUI-thread value updates coalesce naturally to the newest target with no explicit queue.
|
||||
- Renderer frame scheduling remains guarded so continuous callbacks are scheduled only while animation or smoothing is active.
|
||||
- Debug state now includes detailed animation and smoothing fields.
|
||||
- Added `examples/animations.py`, `examples/live_high_rate.py`, and `examples/live_worst_case.py` using GUI-thread frame callbacks for Dear PyGui updates.
|
||||
- Added Step 6 tests for animation convergence, smoothing reset behaviour, latest-target wins, and renderer scheduling.
|
||||
|
||||
Step 5 - Analog, segmented, compass, thermometer, battery, and multi-value gauges:
|
||||
|
||||
@@ -91,7 +103,7 @@ Implemented during Step 2:
|
||||
|
||||
## Known issues
|
||||
|
||||
Manual visual example checks for Steps 3, 4, and 5 were not run in this headless session.
|
||||
Manual visual/stress example checks for Steps 3, 4, 5, and 6 were not run in this headless session.
|
||||
|
||||
## Commands used
|
||||
|
||||
@@ -118,6 +130,14 @@ Manual visual example checks for Steps 3, 4, and 5 were not run in this headless
|
||||
- Ran `uv run ruff format .`.
|
||||
- Ran `uv run ruff format --check .`.
|
||||
- Ran `uv run pyright`.
|
||||
- Committed Step 5 with `git commit -m "step 5: complete first gauge catalogue"` before starting Step 6.
|
||||
- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 6 changes.
|
||||
- Implemented Step 6 animation/smoothing scheduling refinements, high-rate examples, README status update, diagnostics fields, and tests.
|
||||
- Ran `uv run pytest`.
|
||||
- Ran `uv run ruff check .`.
|
||||
- Ran `uv run ruff format .`.
|
||||
- Ran `uv run ruff format --check .`.
|
||||
- Ran `uv run pyright`.
|
||||
- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 2 changes.
|
||||
- Implemented Step 2 state, sizing, animation, smoothing, runtime API, diagnostics, tests, and README status update.
|
||||
- Ran `uv run pytest`.
|
||||
@@ -142,4 +162,4 @@ Manual visual example checks for Steps 3, 4, and 5 were not run in this headless
|
||||
|
||||
## Next action
|
||||
|
||||
Commit Step 5, then implement Step 6.
|
||||
Commit Step 6, then implement Step 7.
|
||||
|
||||
51
examples/animations.py
Normal file
51
examples/animations.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
|
||||
import math
|
||||
import time
|
||||
|
||||
import dearpygui.dearpygui as dpg
|
||||
|
||||
import dpg_gauges as dpgg
|
||||
|
||||
|
||||
def main() -> None:
|
||||
dpg.create_context()
|
||||
started = time.monotonic()
|
||||
|
||||
def update() -> None:
|
||||
elapsed = time.monotonic() - started
|
||||
dpgg.set_value("animated", 50 + math.sin(elapsed * 2.0) * 50)
|
||||
dpgg.set_value("smoothed", 50 + math.sin(elapsed * 7.0) * 50)
|
||||
dpg.set_frame_callback(dpg.get_frame_count() + 1, update)
|
||||
|
||||
try:
|
||||
with dpg.window(label="Animations", width=460, height=260):
|
||||
dpgg.digital_gauge(
|
||||
tag="animated",
|
||||
label="Animated",
|
||||
value=0,
|
||||
width=360,
|
||||
height=90,
|
||||
animation=dpgg.AnimationConfig(enabled=True, duration=0.35),
|
||||
)
|
||||
dpgg.bar_gauge(
|
||||
tag="smoothed",
|
||||
label="Smoothed",
|
||||
value=0,
|
||||
width=360,
|
||||
height=90,
|
||||
animation=False,
|
||||
smoothing=dpgg.SmoothingConfig(enabled=True, alpha=0.18, max_step=8),
|
||||
)
|
||||
|
||||
dpg.create_viewport(title="dpg-gauges animations", width=500, height=300)
|
||||
dpg.setup_dearpygui()
|
||||
dpg.set_frame_callback(1, update)
|
||||
dpg.show_viewport()
|
||||
dpg.start_dearpygui()
|
||||
finally:
|
||||
dpg.destroy_context()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
73
examples/live_high_rate.py
Normal file
73
examples/live_high_rate.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
|
||||
import dearpygui.dearpygui as dpg
|
||||
|
||||
import dpg_gauges as dpgg
|
||||
|
||||
|
||||
def main() -> None:
|
||||
latest = {"rpm": 0.0, "speed": 0.0}
|
||||
lock = threading.Lock()
|
||||
stop = threading.Event()
|
||||
|
||||
def producer() -> None:
|
||||
started = time.monotonic()
|
||||
while not stop.is_set():
|
||||
elapsed = time.monotonic() - started
|
||||
with lock:
|
||||
latest["rpm"] = 4000 + math.sin(elapsed * 9.0) * 3500
|
||||
latest["speed"] = 120 + math.sin(elapsed * 3.0) * 80
|
||||
time.sleep(1 / 240)
|
||||
|
||||
def gui_update() -> None:
|
||||
with lock:
|
||||
rpm = latest["rpm"]
|
||||
speed = latest["speed"]
|
||||
dpgg.set_value("rpm", rpm)
|
||||
dpgg.set_value("speed", speed)
|
||||
dpg.set_frame_callback(dpg.get_frame_count() + 1, gui_update)
|
||||
|
||||
dpg.create_context()
|
||||
thread = threading.Thread(target=producer, daemon=True)
|
||||
try:
|
||||
with dpg.window(label="High Rate", width=620, height=360):
|
||||
dpgg.analog_gauge(
|
||||
tag="rpm",
|
||||
label="RPM",
|
||||
value=0,
|
||||
max_value=8000,
|
||||
redline_start=6500,
|
||||
width=240,
|
||||
height=240,
|
||||
animation=False,
|
||||
smoothing=dpgg.SmoothingConfig(enabled=True, alpha=0.22, max_step=500),
|
||||
)
|
||||
dpgg.digital_gauge(
|
||||
tag="speed",
|
||||
label="Speed",
|
||||
value=0,
|
||||
max_value=240,
|
||||
unit="km/h",
|
||||
width=260,
|
||||
height=90,
|
||||
animation=dpgg.AnimationConfig(enabled=True, duration=0.12),
|
||||
)
|
||||
|
||||
dpg.create_viewport(title="dpg-gauges high rate", width=660, height=400)
|
||||
dpg.setup_dearpygui()
|
||||
thread.start()
|
||||
dpg.set_frame_callback(1, gui_update)
|
||||
dpg.show_viewport()
|
||||
dpg.start_dearpygui()
|
||||
finally:
|
||||
stop.set()
|
||||
thread.join(timeout=1.0)
|
||||
dpg.destroy_context()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
58
examples/live_worst_case.py
Normal file
58
examples/live_worst_case.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
|
||||
import math
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
|
||||
import dearpygui.dearpygui as dpg
|
||||
|
||||
import dpg_gauges as dpgg
|
||||
|
||||
|
||||
def main() -> None:
|
||||
latest = {"rpm": 0.0, "boost": 0.0, "fuel": 0.0, "battery": 0.0}
|
||||
lock = threading.Lock()
|
||||
stop = threading.Event()
|
||||
|
||||
def producer() -> None:
|
||||
started = time.monotonic()
|
||||
while not stop.is_set():
|
||||
elapsed = time.monotonic() - started
|
||||
with lock:
|
||||
latest["rpm"] = random.uniform(0, 8000)
|
||||
latest["boost"] = 15 + math.sin(elapsed * 25.0) * 15
|
||||
latest["fuel"] = 50 + math.sin(elapsed * 4.0) * 50
|
||||
latest["battery"] = random.uniform(0, 100)
|
||||
time.sleep(1 / 500)
|
||||
|
||||
def gui_update() -> None:
|
||||
with lock:
|
||||
snapshot = dict(latest)
|
||||
for tag, value in snapshot.items():
|
||||
dpgg.set_value(tag, value)
|
||||
dpg.set_frame_callback(dpg.get_frame_count() + 1, gui_update)
|
||||
|
||||
dpg.create_context()
|
||||
thread = threading.Thread(target=producer, daemon=True)
|
||||
try:
|
||||
with dpg.window(label="Worst Case", width=760, height=420), dpgg.gauge_grid(columns=4):
|
||||
dpgg.analog_gauge(tag="rpm", label="RPM", max_value=8000, width=180, height=180)
|
||||
dpgg.bar_gauge(tag="boost", label="Boost", max_value=30, width=180, height=90)
|
||||
dpgg.level_gauge(tag="fuel", label="Fuel", width=120, height=180)
|
||||
dpgg.battery_gauge(tag="battery", label="Battery", width=180, height=90)
|
||||
|
||||
dpg.create_viewport(title="dpg-gauges worst case", width=800, height=460)
|
||||
dpg.setup_dearpygui()
|
||||
thread.start()
|
||||
dpg.set_frame_callback(1, gui_update)
|
||||
dpg.show_viewport()
|
||||
dpg.start_dearpygui()
|
||||
finally:
|
||||
stop.set()
|
||||
thread.join(timeout=1.0)
|
||||
dpg.destroy_context()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -25,6 +25,17 @@ def get_gauge_debug_state(tag: Tag) -> dict[str, Any]:
|
||||
"config_revision": state.config_revision,
|
||||
"animation_active": state.animation_state.active,
|
||||
"smoothing_active": state.smoothing_state.active,
|
||||
"animation": {
|
||||
"start_value": state.animation_state.start_value,
|
||||
"target_value": state.animation_state.target_value,
|
||||
"started_at": state.animation_state.started_at,
|
||||
"duration": state.animation_state.duration,
|
||||
"active": state.animation_state.active,
|
||||
},
|
||||
"smoothing": {
|
||||
"value": state.smoothing_state.value,
|
||||
"active": state.smoothing_state.active,
|
||||
},
|
||||
}
|
||||
if state.renderer is not None:
|
||||
debug["renderer"] = state.renderer.debug_state()
|
||||
|
||||
@@ -31,9 +31,16 @@ from .scales import (
|
||||
)
|
||||
from .sizing import effective_size, square_gauge_rect, update_measured_size
|
||||
from .smoothing import advance_smoothing
|
||||
from .state import DirtyFlags, GaugeState, animation_config, size_state_for_gauge, update_state_size
|
||||
from .state import (
|
||||
DirtyFlags,
|
||||
GaugeState,
|
||||
animation_config,
|
||||
size_state_for_gauge,
|
||||
smoothing_config,
|
||||
update_state_size,
|
||||
)
|
||||
from .themes import GaugeStyle
|
||||
from .types import SmoothingConfig, StatusState, Tag
|
||||
from .types import StatusState, Tag
|
||||
|
||||
Rect = tuple[float, float, float, float]
|
||||
|
||||
@@ -97,7 +104,7 @@ class GaugeRenderer:
|
||||
if state.animation_state.active:
|
||||
state.displayed_value, active = advance_animation(state.animation_state, config, now)
|
||||
state.animation_state.active = active
|
||||
smoothing = _smoothing_config(state)
|
||||
smoothing = smoothing_config(state.config)
|
||||
if not state.animation_state.active and smoothing is not None and smoothing.enabled:
|
||||
state.displayed_value, _ = advance_smoothing(
|
||||
state.smoothing_state, state.clamped_value, smoothing
|
||||
@@ -640,13 +647,6 @@ def render_attached_gauge(state: GaugeState) -> None:
|
||||
state.renderer.render()
|
||||
|
||||
|
||||
def _smoothing_config(state: GaugeState) -> SmoothingConfig | None:
|
||||
smoothing = state.config.smoothing
|
||||
if isinstance(smoothing, bool):
|
||||
return SmoothingConfig(enabled=smoothing)
|
||||
return smoothing
|
||||
|
||||
|
||||
def _drawlist_tag(state: GaugeState) -> Tag:
|
||||
return cast(Tag, state.drawlist_tag)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass, field, replace
|
||||
from enum import IntFlag, auto
|
||||
from typing import Any
|
||||
@@ -144,6 +145,13 @@ def animation_config(config: GaugeConfig) -> AnimationConfig | None:
|
||||
return animation
|
||||
|
||||
|
||||
def smoothing_config(config: GaugeConfig) -> SmoothingConfig | None:
|
||||
smoothing = config.smoothing
|
||||
if isinstance(smoothing, bool):
|
||||
return SmoothingConfig(enabled=smoothing)
|
||||
return smoothing
|
||||
|
||||
|
||||
def clamp_runtime_value(value: GaugeValue, config: GaugeConfig) -> tuple[float | None, bool]:
|
||||
numeric = coerce_numeric_value(value)
|
||||
if numeric is None:
|
||||
@@ -203,8 +211,9 @@ def get_gauge_state(tag: Tag) -> GaugeState:
|
||||
raise GaugeNotFoundError(msg) from exc
|
||||
|
||||
|
||||
def set_runtime_value(tag: Tag, value: GaugeValue, *, now: float = 0.0) -> None:
|
||||
def set_runtime_value(tag: Tag, value: GaugeValue, *, now: float | None = None) -> None:
|
||||
state = get_gauge_state(tag)
|
||||
now = time.monotonic() if now is None else now
|
||||
clamped, invalid = clamp_runtime_value(value, state.config)
|
||||
state.target_value = value
|
||||
state.clamped_value = clamped
|
||||
@@ -215,8 +224,15 @@ def set_runtime_value(tag: Tag, value: GaugeValue, *, now: float = 0.0) -> None:
|
||||
state.displayed_value, clamped, animation_config(state.config), now
|
||||
)
|
||||
if not state.animation_state.active:
|
||||
state.displayed_value = clamped
|
||||
state.smoothing_state.value = clamped
|
||||
smoothing = smoothing_config(state.config)
|
||||
if smoothing is not None and smoothing.enabled and clamped is not None and not invalid:
|
||||
if state.smoothing_state.value is None:
|
||||
state.smoothing_state.value = state.previous_displayed_value
|
||||
state.smoothing_state.active = state.smoothing_state.value != clamped
|
||||
else:
|
||||
state.displayed_value = clamped
|
||||
state.smoothing_state.value = clamped
|
||||
state.smoothing_state.active = False
|
||||
state.dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT
|
||||
render_runtime_gauge(state)
|
||||
|
||||
|
||||
101
tests/test_step6_animation_stress.py
Normal file
101
tests/test_step6_animation_stress.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import dearpygui.dearpygui as dpg
|
||||
import pytest
|
||||
|
||||
import dpg_gauges as dpgg
|
||||
from dpg_gauges.animation import advance_animation
|
||||
from dpg_gauges.state import clear_registry, get_gauge_state, set_runtime_value
|
||||
from dpg_gauges.types import AnimationConfig, SmoothingConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dpg_context() -> Generator[None]:
|
||||
clear_registry()
|
||||
dpgg.configure()
|
||||
dpg.create_context()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
clear_registry()
|
||||
dpg.destroy_context()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_registry() -> Generator[None]:
|
||||
clear_registry()
|
||||
dpgg.configure()
|
||||
yield
|
||||
clear_registry()
|
||||
|
||||
|
||||
def test_animation_converges_to_latest_target() -> None:
|
||||
dpgg.digital_gauge(tag="anim", value=0, animation=AnimationConfig(enabled=True, duration=1.0))
|
||||
|
||||
set_runtime_value("anim", 100, now=10.0)
|
||||
state = get_gauge_state("anim")
|
||||
config = state.config.animation
|
||||
assert isinstance(config, AnimationConfig)
|
||||
value, active = advance_animation(state.animation_state, config, now=10.5)
|
||||
assert value == 50
|
||||
assert active is True
|
||||
|
||||
value, active = advance_animation(state.animation_state, config, now=11.0)
|
||||
assert value == 100
|
||||
assert active is False
|
||||
|
||||
|
||||
def test_smoothing_progresses_and_resets_on_range_change() -> None:
|
||||
dpgg.digital_gauge(
|
||||
tag="smooth",
|
||||
value=0,
|
||||
smoothing=SmoothingConfig(enabled=True, alpha=0.5),
|
||||
)
|
||||
|
||||
set_runtime_value("smooth", 100, now=1.0)
|
||||
state = get_gauge_state("smooth")
|
||||
assert state.displayed_value == 0
|
||||
assert state.smoothing_state.active is True
|
||||
|
||||
dpgg.configure_gauge("smooth", max_value=50)
|
||||
assert state.displayed_value == 50
|
||||
assert state.smoothing_state.value == 50
|
||||
|
||||
|
||||
def test_latest_target_wins_without_queue_growth() -> None:
|
||||
dpgg.digital_gauge(tag="latest", value=0, animation=AnimationConfig(enabled=True, duration=1.0))
|
||||
|
||||
set_runtime_value("latest", 25, now=1.0)
|
||||
set_runtime_value("latest", 75, now=1.1)
|
||||
set_runtime_value("latest", 50, now=1.2)
|
||||
|
||||
state = get_gauge_state("latest")
|
||||
assert state.target_value == 50
|
||||
assert state.clamped_value == 50
|
||||
assert state.animation_state.target_value == 50
|
||||
assert state.value_revision == 3
|
||||
|
||||
|
||||
def test_attached_renderer_schedules_only_while_animation_active(dpg_context: None) -> None:
|
||||
with dpg.window(label="Animation"):
|
||||
dpgg.digital_gauge(
|
||||
tag="attached",
|
||||
value=0,
|
||||
width=180,
|
||||
height=80,
|
||||
animation=AnimationConfig(enabled=True, duration=1.0),
|
||||
)
|
||||
|
||||
dpgg.set_value("attached", 100)
|
||||
debug = dpgg.get_gauge_debug_state("attached")
|
||||
assert debug["renderer"]["scheduled"] is True
|
||||
assert debug["animation"]["target_value"] == 100
|
||||
|
||||
state = get_gauge_state("attached")
|
||||
state.animation_state.active = False
|
||||
state.smoothing_state.active = False
|
||||
state.renderer._scheduled = False
|
||||
state.renderer.render()
|
||||
assert dpgg.get_gauge_debug_state("attached")["renderer"]["scheduled"] is False
|
||||
Reference in New Issue
Block a user