diff --git a/README.md b/README.md index 39c41d1..008b6f3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/codex/AGENTS.md b/codex/AGENTS.md index aca5430..304708e 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -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. diff --git a/examples/animations.py b/examples/animations.py new file mode 100644 index 0000000..ff15dc9 --- /dev/null +++ b/examples/animations.py @@ -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() diff --git a/examples/live_high_rate.py b/examples/live_high_rate.py new file mode 100644 index 0000000..6149977 --- /dev/null +++ b/examples/live_high_rate.py @@ -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() diff --git a/examples/live_worst_case.py b/examples/live_worst_case.py new file mode 100644 index 0000000..730a4ed --- /dev/null +++ b/examples/live_worst_case.py @@ -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() diff --git a/src/dpg_gauges/diagnostics.py b/src/dpg_gauges/diagnostics.py index 14d1c19..d3a7d15 100644 --- a/src/dpg_gauges/diagnostics.py +++ b/src/dpg_gauges/diagnostics.py @@ -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() diff --git a/src/dpg_gauges/renderer.py b/src/dpg_gauges/renderer.py index 1b55107..135b8de 100644 --- a/src/dpg_gauges/renderer.py +++ b/src/dpg_gauges/renderer.py @@ -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) diff --git a/src/dpg_gauges/state.py b/src/dpg_gauges/state.py index 3ddce76..ddd7a62 100644 --- a/src/dpg_gauges/state.py +++ b/src/dpg_gauges/state.py @@ -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) diff --git a/tests/test_step6_animation_stress.py b/tests/test_step6_animation_stress.py new file mode 100644 index 0000000..a50522e --- /dev/null +++ b/tests/test_step6_animation_stress.py @@ -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