From 34fd60c34e0b58bc875e519394b185af1c5daf65 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Thu, 2 Jul 2026 13:54:58 +0200 Subject: [PATCH 1/8] step 2: add gauge state sizing and animation model --- .gitignore | 5 + .python-version | 1 + README.md | 9 + codex/AGENTS.md | 57 +++++- codex/STEPS.md | 3 +- docs/.gitkeep | 0 examples/.gitkeep | 0 pyproject.toml | 36 ++++ src/dpg_gauges/__init__.py | 74 ++++++++ src/dpg_gauges/animation.py | 72 ++++++++ src/dpg_gauges/api.py | 129 ++++++++++++++ src/dpg_gauges/diagnostics.py | 28 +++ src/dpg_gauges/draw_layers.py | 0 src/dpg_gauges/exceptions.py | 25 +++ src/dpg_gauges/gauges.py | 150 ++++++++++++++++ src/dpg_gauges/renderer.py | 0 src/dpg_gauges/scales.py | 104 +++++++++++ src/dpg_gauges/sizing.py | 64 +++++++ src/dpg_gauges/smoothing.py | 45 +++++ src/dpg_gauges/state.py | 305 ++++++++++++++++++++++++++++++++ src/dpg_gauges/themes.py | 76 ++++++++ src/dpg_gauges/types.py | 107 +++++++++++ src/dpg_gauges/widget.py | 0 tests/.gitkeep | 0 tests/test_step1_core.py | 117 ++++++++++++ tests/test_step2_state_model.py | 162 +++++++++++++++++ uv.lock | 168 ++++++++++++++++++ 27 files changed, 1732 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 docs/.gitkeep create mode 100644 examples/.gitkeep create mode 100644 pyproject.toml create mode 100644 src/dpg_gauges/__init__.py create mode 100644 src/dpg_gauges/animation.py create mode 100644 src/dpg_gauges/api.py create mode 100644 src/dpg_gauges/diagnostics.py create mode 100644 src/dpg_gauges/draw_layers.py create mode 100644 src/dpg_gauges/exceptions.py create mode 100644 src/dpg_gauges/gauges.py create mode 100644 src/dpg_gauges/renderer.py create mode 100644 src/dpg_gauges/scales.py create mode 100644 src/dpg_gauges/sizing.py create mode 100644 src/dpg_gauges/smoothing.py create mode 100644 src/dpg_gauges/state.py create mode 100644 src/dpg_gauges/themes.py create mode 100644 src/dpg_gauges/types.py create mode 100644 src/dpg_gauges/widget.py create mode 100644 tests/.gitkeep create mode 100644 tests/test_step1_core.py create mode 100644 tests/test_step2_state_model.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..999f5fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.venv/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e19716f --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# dpg-gauges + +Dear PyGui gauge widgets for dashboards and telemetry displays. + +Current implementation status: Step 2 logical state is implemented. Gauge creation functions build +and register pure gauge state without requiring a Dear PyGui context; final widget shells and drawing +arrive in later steps. + +Runtime value updates are intended for the Dear PyGui GUI thread once rendering is implemented. diff --git a/codex/AGENTS.md b/codex/AGENTS.md index 668df4e..bc0d087 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -2,15 +2,44 @@ ## Current status -Project instruction set initialized. +Step 2 completed and verified. Ready to start Step 3. ## Completed steps -None yet. +Initial setup before Step 1: + +- `uv` package metadata exists. +- Runtime dependency `dearpygui` is installed. +- Dev dependencies `pytest`, `ruff`, and `pyright` are installed. +- Required package skeleton exists under `src/dpg_gauges/`. +- `examples/`, `tests/`, and `docs/` directories exist. +- `pyproject.toml` is configured for Python 3.11+, Ruff, and Pyright. + +Step 1 - Public API contract and pure core: + +- Public API exports are defined in `src/dpg_gauges/__init__.py`. +- Public exceptions are defined in `src/dpg_gauges/exceptions.py`. +- Shared public dataclasses and type aliases are defined in `src/dpg_gauges/types.py`. +- Pure gauge configuration dataclasses are defined in `src/dpg_gauges/gauges.py`. +- Pure scale helpers are defined in `src/dpg_gauges/scales.py`. +- Lightweight theme/style presets are defined in `src/dpg_gauges/themes.py`. +- GUI-dependent public functions are import-safe stubs for Step 1. +- Step 1 tests cover public imports, dataclass construction, clamp/normalize math, tick generation, threshold validation, formatting, and missing-gauge stubs. ## Current step -Step 1 - Public API contract and pure core. +Step 3 - Widget shell and renderer foundation. + +Step 2 - State, sizing, animation, and smoothing model: + +Implemented during Step 2: + +- Added `DpgGaugesConfig`, `configure(...)` wiring, `GaugeState`, `DirtyFlags`, and the global gauge registry. +- Runtime API functions now mutate logical state for values, config updates, thresholds, markers, visibility, and deletion without Dear PyGui calls. +- Added pure sizing helpers for requested/measured size, last non-zero size preservation, square rects, and stretch rects. +- Added pure animation interpolation helpers and smoothing progression helpers. +- Added debug state reporting for registered gauges. +- Added Step 2 tests for registry behaviour, clamped/displayed separation, dirty flags, sizing, animation, smoothing, and invalid values. ## Design decisions @@ -25,6 +54,7 @@ Step 1 - Public API contract and pure core. - Gauges are display-only for the first release. - Thresholds and redline zones are visual only; they do not trigger events. - The public API should feel like normal Dear PyGui context-manager usage. +- Each completed build step must be checked, documented in this file, and committed before starting the next step. ## Known issues @@ -35,7 +65,26 @@ None yet. - Read `codex-old/README.md`, `codex-old/FEATURES.md`, `codex-old/ARCHITECTURE.md`, `codex-old/STEPS.md`, and `codex-old/AGENTS.md`. - Inspected `/home/hector/projects/dpg-map` package, examples, tests, docs, and pyproject structure. - Created the initial `codex/` instruction documents for `dpg-gauges`. +- Read `codex/README.md`, `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md`. +- Completed missing pre-Step-1 skeleton files and directories. +- Ran `uv run python -c "import dpg_gauges; print(dpg_gauges.__name__)"`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format .`. +- Read all files under `codex/`. +- Implemented Step 1 public API exports and pure core modules. +- Added `tests/test_step1_core.py`. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- 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`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. +- Updated instructions to require a commit after each completed step. ## Next action -Implement Step 1. +Implement Step 3. diff --git a/codex/STEPS.md b/codex/STEPS.md index 347b102..059cc9d 100644 --- a/codex/STEPS.md +++ b/codex/STEPS.md @@ -8,12 +8,13 @@ There is no Step 0. Initial setup is listed separately, then implementation star 2. Always read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, and `codex/AGENTS.md` before making code changes. 3. Keep `codex/AGENTS.md` as a rolling log of what has been done, what is broken, and what comes next. 4. Update `README.md` and `docs/` whenever public behaviour or examples change. -5. After every step, update `codex/AGENTS.md`, run relevant checks, and commit if working in git. +5. After every step, update `codex/AGENTS.md`, run relevant checks, and commit the completed step when working in git. 6. Do not casually change public API once introduced. 7. Dear PyGui calls must happen on the GUI thread only. 8. Runtime gauge updates are GUI-thread-only unless explicitly documented otherwise. 9. Prefer stable, boring implementation over clever drawing tricks. 10. Keep each step shippable and tested before moving to the next. +11. Do not start the next step while the previous completed step is still uncommitted. ## Initial setup instructions diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/.gitkeep b/examples/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6253b55 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "dpg-gauges" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +authors = [ + { name = "Hector van der Aa", email = "hector@h3cx.dev" } +] +requires-python = ">=3.11" +dependencies = [ + "dearpygui>=2.3.1", +] + +[project.scripts] +dpg-gauges = "dpg_gauges:main" + +[build-system] +requires = ["uv_build>=0.11.21,<0.12.0"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "pyright>=1.1.411", + "pytest>=9.1.1", + "ruff>=0.15.20", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.pyright] +typeCheckingMode = "basic" diff --git a/src/dpg_gauges/__init__.py b/src/dpg_gauges/__init__.py new file mode 100644 index 0000000..667dea2 --- /dev/null +++ b/src/dpg_gauges/__init__.py @@ -0,0 +1,74 @@ +"""Dear PyGui gauge widgets.""" + +from .api import ( + analog_gauge, + bar_gauge, + battery_gauge, + compass_gauge, + configure, + configure_gauge, + delete_gauge, + digital_gauge, + gauge_grid, + gauge_panel, + get_value, + level_gauge, + multi_value_gauge, + segmented_gauge, + set_markers, + set_show, + set_thresholds, + set_value, + status_light, + thermometer_gauge, + update_gauge, +) +from .diagnostics import get_gauge_debug_state +from .themes import GaugeStyle, GaugeTheme +from .types import ( + AnimationConfig, + GaugeMarker, + GaugeStats, + GaugeValue, + SmoothingConfig, + StatusState, + ThresholdBand, +) + +__all__ = [ + "configure", + "GaugeTheme", + "GaugeStyle", + "AnimationConfig", + "SmoothingConfig", + "ThresholdBand", + "GaugeMarker", + "StatusState", + "GaugeValue", + "GaugeStats", + "analog_gauge", + "digital_gauge", + "bar_gauge", + "level_gauge", + "status_light", + "segmented_gauge", + "compass_gauge", + "thermometer_gauge", + "battery_gauge", + "multi_value_gauge", + "gauge_panel", + "gauge_grid", + "set_value", + "get_value", + "update_gauge", + "configure_gauge", + "set_thresholds", + "set_markers", + "set_show", + "delete_gauge", + "get_gauge_debug_state", +] + + +def main() -> None: + print("dpg-gauges is a library package; import dpg_gauges to use it.") diff --git a/src/dpg_gauges/animation.py b/src/dpg_gauges/animation.py new file mode 100644 index 0000000..9003436 --- /dev/null +++ b/src/dpg_gauges/animation.py @@ -0,0 +1,72 @@ +"""Pure animation interpolation helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .types import AnimationConfig + + +@dataclass +class AnimationState: + start_value: float | None = None + target_value: float | None = None + started_at: float = 0.0 + duration: float = 0.0 + active: bool = False + + +def easing_fraction(fraction: float, easing: str = "linear") -> float: + fraction = min(max(fraction, 0.0), 1.0) + if easing == "linear": + return fraction + if easing == "ease_in": + return fraction * fraction + if easing == "ease_out": + return 1.0 - (1.0 - fraction) * (1.0 - fraction) + if easing == "ease_in_out": + if fraction < 0.5: + return 2.0 * fraction * fraction + return 1.0 - ((-2.0 * fraction + 2.0) ** 2) / 2.0 + return fraction + + +def interpolate(start: float, end: float, fraction: float, easing: str = "linear") -> float: + eased = easing_fraction(fraction, easing) + return start + (end - start) * eased + + +def start_animation( + displayed_value: float | None, + target_value: float | None, + config: AnimationConfig | None, + now: float, +) -> AnimationState: + if displayed_value is None or target_value is None: + return AnimationState(target_value, target_value, now, 0.0, False) + if ( + config is None + or not config.enabled + or config.duration <= 0 + or displayed_value == target_value + ): + return AnimationState(target_value, target_value, now, 0.0, False) + return AnimationState(displayed_value, target_value, now, config.duration, True) + + +def advance_animation( + state: AnimationState, config: AnimationConfig | None, now: float +) -> tuple[float | None, bool]: + if not state.active: + return state.target_value, False + if state.start_value is None or state.target_value is None or state.duration <= 0: + state.active = False + return state.target_value, False + + fraction = (now - state.started_at) / state.duration + if fraction >= 1.0: + state.active = False + return state.target_value, False + + easing = config.easing if config is not None else "linear" + return interpolate(state.start_value, state.target_value, fraction, easing), True diff --git a/src/dpg_gauges/api.py b/src/dpg_gauges/api.py new file mode 100644 index 0000000..8989b25 --- /dev/null +++ b/src/dpg_gauges/api.py @@ -0,0 +1,129 @@ +"""Public API wrappers for logical gauge state.""" + +from __future__ import annotations + +from contextlib import nullcontext +from typing import Any, cast + +from .gauges import ( + AnalogGaugeConfig, + BarGaugeConfig, + BatteryGaugeConfig, + CompassGaugeConfig, + DigitalGaugeConfig, + GaugeConfig, + LevelGaugeConfig, + MultiValueGaugeConfig, + SegmentedGaugeConfig, + StatusLightConfig, + ThermometerGaugeConfig, +) +from .state import ( + configure_gauges, + delete_runtime_gauge, + get_runtime_value, + register_gauge, + set_runtime_markers, + set_runtime_show, + set_runtime_thresholds, + set_runtime_value, + update_runtime_gauge, +) +from .types import GaugeMarker, GaugeValue, Tag, ThresholdBand + + +def configure(**config: Any) -> None: + configure_gauges(**config) + + +def analog_gauge(**config: Any) -> nullcontext[AnalogGaugeConfig]: + state = register_gauge("analog", AnalogGaugeConfig(**config)) + return nullcontext(cast(AnalogGaugeConfig, state.config)) + + +def digital_gauge(**config: Any) -> nullcontext[DigitalGaugeConfig]: + state = register_gauge("digital", DigitalGaugeConfig(**config)) + return nullcontext(cast(DigitalGaugeConfig, state.config)) + + +def bar_gauge(**config: Any) -> nullcontext[BarGaugeConfig]: + state = register_gauge("bar", BarGaugeConfig(**config)) + return nullcontext(cast(BarGaugeConfig, state.config)) + + +def level_gauge(**config: Any) -> nullcontext[LevelGaugeConfig]: + state = register_gauge("level", LevelGaugeConfig(**config)) + return nullcontext(cast(LevelGaugeConfig, state.config)) + + +def status_light(**config: Any) -> nullcontext[StatusLightConfig]: + state = register_gauge("status_light", StatusLightConfig(**config)) + return nullcontext(cast(StatusLightConfig, state.config)) + + +def segmented_gauge(**config: Any) -> nullcontext[SegmentedGaugeConfig]: + state = register_gauge("segmented", SegmentedGaugeConfig(**config)) + return nullcontext(cast(SegmentedGaugeConfig, state.config)) + + +def compass_gauge(**config: Any) -> nullcontext[CompassGaugeConfig]: + state = register_gauge("compass", CompassGaugeConfig(**config)) + return nullcontext(cast(CompassGaugeConfig, state.config)) + + +def thermometer_gauge(**config: Any) -> nullcontext[ThermometerGaugeConfig]: + state = register_gauge("thermometer", ThermometerGaugeConfig(**config)) + return nullcontext(cast(ThermometerGaugeConfig, state.config)) + + +def battery_gauge(**config: Any) -> nullcontext[BatteryGaugeConfig]: + state = register_gauge("battery", BatteryGaugeConfig(**config)) + return nullcontext(cast(BatteryGaugeConfig, state.config)) + + +def multi_value_gauge(**config: Any) -> nullcontext[MultiValueGaugeConfig]: + state = register_gauge("multi_value", MultiValueGaugeConfig(**config)) + return nullcontext(cast(MultiValueGaugeConfig, state.config)) + + +def gauge_panel(**_config: Any) -> nullcontext[None]: + return nullcontext(None) + + +def gauge_grid(**_config: Any) -> nullcontext[None]: + return nullcontext(None) + + +def set_value(tag: Tag, value: GaugeValue) -> None: + set_runtime_value(tag, value) + + +def get_value(tag: Tag) -> GaugeValue: + return get_runtime_value(tag) + + +def update_gauge(tag: Tag, **config: Any) -> None: + update_runtime_gauge(tag, **config) + + +def configure_gauge(tag: Tag, **config: Any) -> None: + update_runtime_gauge(tag, **config) + + +def set_thresholds(tag: Tag, thresholds: list[ThresholdBand] | tuple[ThresholdBand, ...]) -> None: + set_runtime_thresholds(tag, thresholds) + + +def set_markers(tag: Tag, markers: list[GaugeMarker] | tuple[GaugeMarker, ...]) -> None: + set_runtime_markers(tag, markers) + + +def set_show(tag: Tag, show: bool) -> None: + set_runtime_show(tag, show) + + +def delete_gauge(tag: Tag) -> None: + delete_runtime_gauge(tag) + + +__all_configs__ = (GaugeConfig,) diff --git a/src/dpg_gauges/diagnostics.py b/src/dpg_gauges/diagnostics.py new file mode 100644 index 0000000..d9326aa --- /dev/null +++ b/src/dpg_gauges/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics helpers.""" + +from __future__ import annotations + +from typing import Any + +from .state import get_gauge_state +from .types import Tag + + +def get_gauge_debug_state(tag: Tag) -> dict[str, Any]: + state = get_gauge_state(tag) + return { + "tag": state.tag, + "gauge_type": state.gauge_type, + "target_value": state.target_value, + "clamped_value": state.clamped_value, + "displayed_value": state.displayed_value, + "invalid_value": state.invalid_value, + "measured_size": (state.measured_width, state.measured_height), + "last_nonzero_size": (state.last_nonzero_width, state.last_nonzero_height), + "dirty": state.dirty, + "dirty_names": [flag.name for flag in type(state.dirty) if flag in state.dirty], + "value_revision": state.value_revision, + "config_revision": state.config_revision, + "animation_active": state.animation_state.active, + "smoothing_active": state.smoothing_state.active, + } diff --git a/src/dpg_gauges/draw_layers.py b/src/dpg_gauges/draw_layers.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dpg_gauges/exceptions.py b/src/dpg_gauges/exceptions.py new file mode 100644 index 0000000..d905efd --- /dev/null +++ b/src/dpg_gauges/exceptions.py @@ -0,0 +1,25 @@ +"""Public exceptions for dpg-gauges.""" + + +class DpgGaugesError(Exception): + """Base exception for all dpg-gauges errors.""" + + +class GaugeNotFoundError(DpgGaugesError): + """Raised when a gauge tag cannot be found.""" + + +class GaugeConfigError(DpgGaugesError): + """Raised when gauge configuration is invalid.""" + + +class GaugeValueError(DpgGaugesError): + """Raised when a gauge value cannot be accepted.""" + + +class GaugeTypeError(DpgGaugesError): + """Raised when a gauge type is invalid or unsupported.""" + + +class ThreadingError(DpgGaugesError): + """Raised when Dear PyGui operations are called from the wrong thread.""" diff --git a/src/dpg_gauges/gauges.py b/src/dpg_gauges/gauges.py new file mode 100644 index 0000000..03e8123 --- /dev/null +++ b/src/dpg_gauges/gauges.py @@ -0,0 +1,150 @@ +"""Pure gauge configuration dataclasses.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, replace +from typing import Any, Literal + +from .scales import validate_markers, validate_range, validate_thresholds +from .themes import GaugeStyle, GaugeTheme, get_theme, merge_style +from .types import ( + AnimationConfig, + Callback, + GaugeMarker, + GaugeValue, + SmoothingConfig, + StatusState, + Tag, + ThresholdBand, + ValueFormatter, +) + + +def _normalize_animation(value: AnimationConfig | bool | None) -> AnimationConfig | None: + if isinstance(value, bool): + return AnimationConfig(enabled=value) + return value + + +def _normalize_smoothing(value: SmoothingConfig | bool | None) -> SmoothingConfig | None: + if isinstance(value, bool): + return SmoothingConfig(enabled=value) + return value + + +@dataclass(frozen=True) +class GaugeConfig: + tag: Tag | None = None + label: str | None = None + value: GaugeValue = None + min_value: float = 0.0 + max_value: float = 100.0 + unit: str = "" + precision: int = 0 + width: int = 0 + height: int = 0 + autosize_x: bool = False + autosize_y: bool = False + show: bool = True + show_label: bool = True + show_value: bool = True + show_unit: bool = True + tooltip: str | None = None + callback: Callback | None = None + user_data: Any = None + theme: str | GaugeTheme | None = None + style: GaugeStyle | None = None + animation: AnimationConfig | bool | None = None + smoothing: SmoothingConfig | bool | None = None + clamp: bool = True + format_value: ValueFormatter | None = None + thresholds: Sequence[ThresholdBand] = () + markers: Sequence[GaugeMarker] = () + + def __post_init__(self) -> None: + validate_range(self.min_value, self.max_value) + validate_thresholds(self.thresholds, self.min_value, self.max_value) + validate_markers(self.markers, self.min_value, self.max_value) + if self.precision < 0: + msg = "precision must be non-negative" + raise ValueError(msg) + object.__setattr__(self, "theme", get_theme(self.theme)) + object.__setattr__(self, "style", self.style or get_theme(self.theme).style) + object.__setattr__(self, "animation", _normalize_animation(self.animation)) + object.__setattr__(self, "smoothing", _normalize_smoothing(self.smoothing)) + object.__setattr__(self, "thresholds", tuple(self.thresholds)) + object.__setattr__(self, "markers", tuple(self.markers)) + + def with_style_overrides(self, **overrides: Any) -> GaugeConfig: + return replace(self, style=merge_style(self.style, **overrides)) + + +@dataclass(frozen=True) +class AnalogGaugeConfig(GaugeConfig): + start_angle: float = 225.0 + end_angle: float = -45.0 + major_ticks: int = 8 + minor_ticks: int = 4 + show_tick_labels: bool = True + needle_length: float = 0.82 + needle_width: float = 3.0 + center_cap_radius: float = 7.0 + arc_width: float = 8.0 + maintain_aspect: bool = True + redline_start: float | None = None + + +@dataclass(frozen=True) +class DigitalGaugeConfig(GaugeConfig): + pass + + +@dataclass(frozen=True) +class BarGaugeConfig(GaugeConfig): + orientation: Literal["horizontal", "vertical"] = "horizontal" + fill_mode: Literal["clamp", "centered"] = "clamp" + corner_radius: float = 4.0 + show_scale: bool = True + stretch: bool = True + + +@dataclass(frozen=True) +class LevelGaugeConfig(BarGaugeConfig): + orientation: Literal["horizontal", "vertical"] = "vertical" + + +@dataclass(frozen=True) +class StatusLightConfig(GaugeConfig): + state: str | bool | int = False + states: Mapping[Any, StatusState] | None = None + blink: bool = False + pulse: bool = False + + +@dataclass(frozen=True) +class SegmentedGaugeConfig(BarGaugeConfig): + segments: int = 10 + + +@dataclass(frozen=True) +class CompassGaugeConfig(AnalogGaugeConfig): + min_value: float = 0.0 + max_value: float = 360.0 + start_angle: float = 90.0 + end_angle: float = -270.0 + + +@dataclass(frozen=True) +class ThermometerGaugeConfig(LevelGaugeConfig): + pass + + +@dataclass(frozen=True) +class BatteryGaugeConfig(LevelGaugeConfig): + charging: bool | None = None + + +@dataclass(frozen=True) +class MultiValueGaugeConfig(GaugeConfig): + values: Mapping[str, GaugeValue] | None = None diff --git a/src/dpg_gauges/renderer.py b/src/dpg_gauges/renderer.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dpg_gauges/scales.py b/src/dpg_gauges/scales.py new file mode 100644 index 0000000..7e49040 --- /dev/null +++ b/src/dpg_gauges/scales.py @@ -0,0 +1,104 @@ +"""Pure scale, formatting, and value mapping helpers.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence + +from .exceptions import GaugeConfigError, GaugeValueError +from .types import GaugeMarker, ThresholdBand + + +def validate_range(min_value: float, max_value: float) -> None: + if not math.isfinite(min_value) or not math.isfinite(max_value): + msg = "gauge range values must be finite" + raise GaugeConfigError(msg) + if min_value >= max_value: + msg = "min_value must be less than max_value" + raise GaugeConfigError(msg) + + +def is_finite_number(value: object) -> bool: + return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value) + + +def clamp_value(value: float, min_value: float, max_value: float) -> float: + validate_range(min_value, max_value) + if not math.isfinite(value): + msg = "value must be finite" + raise GaugeValueError(msg) + return min(max(value, min_value), max_value) + + +def normalize_value( + value: float, min_value: float, max_value: float, *, clamp: bool = True +) -> float: + validate_range(min_value, max_value) + if clamp: + value = clamp_value(value, min_value, max_value) + if not math.isfinite(value): + msg = "value must be finite" + raise GaugeValueError(msg) + return (value - min_value) / (max_value - min_value) + + +def value_to_angle( + value: float, + min_value: float, + max_value: float, + start_angle: float, + end_angle: float, + *, + clamp: bool = True, +) -> float: + fraction = normalize_value(value, min_value, max_value, clamp=clamp) + return start_angle + (end_angle - start_angle) * fraction + + +def fill_fraction(value: float, min_value: float, max_value: float, *, clamp: bool = True) -> float: + return normalize_value(value, min_value, max_value, clamp=clamp) + + +def generate_ticks(min_value: float, max_value: float, count: int) -> list[float]: + validate_range(min_value, max_value) + if count < 2: + msg = "tick count must be at least 2" + raise GaugeConfigError(msg) + step = (max_value - min_value) / (count - 1) + return [min_value + step * index for index in range(count)] + + +def format_gauge_value(value: float, precision: int = 0, unit: str = "") -> str: + if precision < 0: + msg = "precision must be non-negative" + raise GaugeConfigError(msg) + if not math.isfinite(value): + return "--" + formatted = f"{value:.{precision}f}" + return f"{formatted} {unit}" if unit else formatted + + +def validate_thresholds( + thresholds: Sequence[ThresholdBand], min_value: float, max_value: float +) -> None: + validate_range(min_value, max_value) + for threshold in thresholds: + if not math.isfinite(threshold.start) or not math.isfinite(threshold.end): + msg = "threshold values must be finite" + raise GaugeConfigError(msg) + + +def validate_markers(markers: Sequence[GaugeMarker], min_value: float, max_value: float) -> None: + validate_range(min_value, max_value) + for marker in markers: + if not math.isfinite(marker.value): + msg = "marker values must be finite" + raise GaugeConfigError(msg) + + +def clamped_threshold( + threshold: ThresholdBand, min_value: float, max_value: float +) -> tuple[float, float]: + start = clamp_value(threshold.start, min_value, max_value) + end = clamp_value(threshold.end, min_value, max_value) + return min(start, end), max(start, end) diff --git a/src/dpg_gauges/sizing.py b/src/dpg_gauges/sizing.py new file mode 100644 index 0000000..8ed4553 --- /dev/null +++ b/src/dpg_gauges/sizing.py @@ -0,0 +1,64 @@ +"""Pure sizing helpers for gauge layout state.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RequestedSize: + width: int = 0 + height: int = 0 + autosize_x: bool = False + autosize_y: bool = False + + +@dataclass +class SizeState: + requested: RequestedSize = RequestedSize() + measured_width: int = 0 + measured_height: int = 0 + last_nonzero_width: int = 0 + last_nonzero_height: int = 0 + + +@dataclass(frozen=True) +class Rect: + x: float + y: float + width: float + height: float + + +def requested_size( + width: int = 0, height: int = 0, *, autosize_x: bool = False, autosize_y: bool = False +) -> RequestedSize: + return RequestedSize(width, height, autosize_x, autosize_y) + + +def update_measured_size(state: SizeState, width: int, height: int) -> bool: + width = max(width, 0) + height = max(height, 0) + changed = state.measured_width != width or state.measured_height != height + state.measured_width = width + state.measured_height = height + if width > 0: + state.last_nonzero_width = width + if height > 0: + state.last_nonzero_height = height + return changed + + +def effective_size(state: SizeState) -> tuple[int, int]: + width = state.measured_width or state.last_nonzero_width or max(state.requested.width, 0) + height = state.measured_height or state.last_nonzero_height or max(state.requested.height, 0) + return width, height + + +def square_gauge_rect(width: int, height: int) -> Rect: + size = float(max(min(width, height), 0)) + return Rect((width - size) / 2.0, (height - size) / 2.0, size, size) + + +def stretch_gauge_rect(width: int, height: int) -> Rect: + return Rect(0.0, 0.0, float(max(width, 0)), float(max(height, 0))) diff --git a/src/dpg_gauges/smoothing.py b/src/dpg_gauges/smoothing.py new file mode 100644 index 0000000..10f7078 --- /dev/null +++ b/src/dpg_gauges/smoothing.py @@ -0,0 +1,45 @@ +"""Pure smoothing helpers for readable value transitions.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .types import SmoothingConfig + + +@dataclass +class SmoothingState: + value: float | None = None + active: bool = False + + +def smooth_value( + previous: float | None, target: float | None, config: SmoothingConfig | None +) -> tuple[float | None, bool]: + if target is None: + return None, False + if previous is None or config is None or not config.enabled: + return target, False + + delta = target - previous + step = delta * config.alpha + if config.max_step is not None: + step = min(max(step, -config.max_step), config.max_step) + + value = previous + step + active = abs(target - value) > 1e-9 + return value, active + + +def advance_smoothing( + state: SmoothingState, target: float | None, config: SmoothingConfig | None +) -> tuple[float | None, bool]: + value, active = smooth_value(state.value, target, config) + state.value = value + state.active = active + return value, active + + +def reset_smoothing(state: SmoothingState, value: float | None = None) -> None: + state.value = value + state.active = False diff --git a/src/dpg_gauges/state.py b/src/dpg_gauges/state.py new file mode 100644 index 0000000..f62bb18 --- /dev/null +++ b/src/dpg_gauges/state.py @@ -0,0 +1,305 @@ +"""Global configuration, gauge registry, and runtime state.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field, replace +from enum import IntFlag, auto +from typing import Any + +from .animation import AnimationState, start_animation +from .exceptions import GaugeConfigError, GaugeNotFoundError +from .gauges import GaugeConfig +from .scales import clamp_value, validate_markers, validate_thresholds +from .sizing import RequestedSize, SizeState +from .smoothing import SmoothingState +from .themes import GaugeTheme, get_theme +from .types import AnimationConfig, GaugeMarker, GaugeValue, SmoothingConfig, Tag, ThresholdBand + + +class DirtyFlags(IntFlag): + NONE = 0 + VALUE = auto() + STATIC = auto() + DYNAMIC = auto() + TEXT = auto() + SIZE = auto() + STYLE = auto() + DEBUG = auto() + FULL = VALUE | STATIC | DYNAMIC | TEXT | SIZE | STYLE | DEBUG + + +@dataclass +class DpgGaugesConfig: + default_theme: str | GaugeTheme | None = None + animation: AnimationConfig | bool | None = None + smoothing: SmoothingConfig | bool | None = None + default_width: int | None = None + default_height: int | None = None + frame_rate_limit: float | None = None + debug: bool = False + + def __post_init__(self) -> None: + if isinstance(self.animation, bool): + self.animation = AnimationConfig(enabled=self.animation) + if isinstance(self.smoothing, bool): + self.smoothing = SmoothingConfig(enabled=self.smoothing) + if self.default_theme is not None: + self.default_theme = get_theme(self.default_theme) + if self.default_width is not None and self.default_width < -1: + msg = "default_width must be -1, 0, or positive" + raise GaugeConfigError(msg) + if self.default_height is not None and self.default_height < -1: + msg = "default_height must be -1, 0, or positive" + raise GaugeConfigError(msg) + if self.frame_rate_limit is not None and self.frame_rate_limit <= 0: + msg = "frame_rate_limit must be greater than zero" + raise GaugeConfigError(msg) + + +@dataclass +class GaugeState: + tag: Tag + gauge_type: str + container_tag: Tag + drawlist_tag: Tag | None + text_tags: dict[str, Tag] + config: GaugeConfig + target_value: GaugeValue + clamped_value: float | None + displayed_value: float | None + previous_displayed_value: float | None + value_revision: int = 0 + config_revision: int = 0 + measured_width: int = 0 + measured_height: int = 0 + last_nonzero_width: int = 0 + last_nonzero_height: int = 0 + dirty: DirtyFlags = DirtyFlags.FULL + renderer: Any = None + animation_state: AnimationState = field(default_factory=AnimationState) + smoothing_state: SmoothingState = field(default_factory=SmoothingState) + invalid_value: bool = False + + +_config = DpgGaugesConfig() +_registry: dict[Tag, GaugeState] = {} +_next_auto_tag = 1 + + +def configure_gauges(**config: Any) -> DpgGaugesConfig: + global _config + _config = DpgGaugesConfig(**config) + return _config + + +def get_config() -> DpgGaugesConfig: + return _config + + +def clear_registry() -> None: + global _next_auto_tag + _registry.clear() + _next_auto_tag = 1 + + +def registry_size() -> int: + return len(_registry) + + +def resolve_tag(tag: Tag | None) -> Tag: + global _next_auto_tag + if tag is not None: + return tag + tag = f"dpg_gauge_{_next_auto_tag}" + _next_auto_tag += 1 + return tag + + +def apply_global_defaults(config: GaugeConfig) -> GaugeConfig: + updates: dict[str, Any] = {} + if config.theme is None and _config.default_theme is not None: + updates["theme"] = _config.default_theme + if config.animation is None and _config.animation is not None: + updates["animation"] = _config.animation + if config.smoothing is None and _config.smoothing is not None: + updates["smoothing"] = _config.smoothing + if config.width == 0 and _config.default_width is not None: + updates["width"] = _config.default_width + if config.height == 0 and _config.default_height is not None: + updates["height"] = _config.default_height + return replace(config, **updates) if updates else config + + +def coerce_numeric_value(value: GaugeValue) -> float | None: + if isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value): + return float(value) + return None + + +def animation_config(config: GaugeConfig) -> AnimationConfig | None: + animation = config.animation + if isinstance(animation, bool): + return AnimationConfig(enabled=animation) + return animation + + +def clamp_runtime_value(value: GaugeValue, config: GaugeConfig) -> tuple[float | None, bool]: + numeric = coerce_numeric_value(value) + if numeric is None: + return None, value is not None + if not math.isfinite(numeric): + return None, True + if config.clamp: + return clamp_value(numeric, config.min_value, config.max_value), False + return numeric, False + + +def register_gauge( + gauge_type: str, + config: GaugeConfig, + *, + container_tag: Tag | None = None, + drawlist_tag: Tag | None = None, + text_tags: dict[str, Tag] | None = None, +) -> GaugeState: + config = apply_global_defaults(config) + tag = resolve_tag(config.tag) + if tag in _registry: + msg = f"gauge already exists: {tag!r}" + raise GaugeConfigError(msg) + if config.tag != tag: + config = replace(config, tag=tag) + clamped, invalid = clamp_runtime_value(config.value, config) + state = GaugeState( + tag=tag, + gauge_type=gauge_type, + container_tag=container_tag or tag, + drawlist_tag=drawlist_tag, + text_tags=text_tags or {}, + config=config, + target_value=config.value, + clamped_value=clamped, + displayed_value=clamped, + previous_displayed_value=clamped, + measured_width=config.width if config.width > 0 else 0, + measured_height=config.height if config.height > 0 else 0, + last_nonzero_width=config.width if config.width > 0 else 0, + last_nonzero_height=config.height if config.height > 0 else 0, + invalid_value=invalid, + ) + state.smoothing_state.value = clamped + _registry[tag] = state + return state + + +def get_gauge_state(tag: Tag) -> GaugeState: + try: + return _registry[tag] + except KeyError as exc: + msg = f"gauge not found: {tag!r}" + raise GaugeNotFoundError(msg) from exc + + +def set_runtime_value(tag: Tag, value: GaugeValue, *, now: float = 0.0) -> None: + state = get_gauge_state(tag) + clamped, invalid = clamp_runtime_value(value, state.config) + state.target_value = value + state.clamped_value = clamped + state.invalid_value = invalid + state.value_revision += 1 + state.previous_displayed_value = state.displayed_value + state.animation_state = start_animation( + state.displayed_value, clamped, animation_config(state.config), now + ) + if not state.animation_state.active: + state.displayed_value = clamped + state.smoothing_state.value = clamped + state.dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + + +def get_runtime_value(tag: Tag) -> GaugeValue: + return get_gauge_state(tag).target_value + + +def update_runtime_gauge(tag: Tag, **config: Any) -> None: + state = get_gauge_state(tag) + value_marker = object() + value = config.pop("value", value_marker) + dirty = DirtyFlags.NONE + + if config: + old_config = state.config + new_config = replace(old_config, **config) + state.config = new_config + state.config_revision += 1 + + if any(name in config for name in ("min_value", "max_value", "clamp")): + clamped, invalid = clamp_runtime_value(state.target_value, new_config) + state.clamped_value = clamped + state.invalid_value = invalid + state.displayed_value = clamped + state.smoothing_state.value = clamped + dirty |= DirtyFlags.STATIC | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + if any(name in config for name in ("thresholds", "markers")): + dirty |= DirtyFlags.STATIC | DirtyFlags.DYNAMIC + if any( + name in config for name in ("label", "unit", "precision", "show_value", "show_unit") + ): + dirty |= DirtyFlags.TEXT + if any(name in config for name in ("width", "height", "autosize_x", "autosize_y", "show")): + dirty |= DirtyFlags.SIZE | DirtyFlags.STATIC | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + if any(name in config for name in ("theme", "style")): + dirty |= DirtyFlags.STYLE | DirtyFlags.STATIC | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + + if value is not value_marker: + set_runtime_value(tag, value) + dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + + state.dirty |= dirty + + +def set_runtime_thresholds( + tag: Tag, thresholds: list[ThresholdBand] | tuple[ThresholdBand, ...] +) -> None: + state = get_gauge_state(tag) + validate_thresholds(thresholds, state.config.min_value, state.config.max_value) + update_runtime_gauge(tag, thresholds=tuple(thresholds)) + + +def set_runtime_markers(tag: Tag, markers: list[GaugeMarker] | tuple[GaugeMarker, ...]) -> None: + state = get_gauge_state(tag) + validate_markers(markers, state.config.min_value, state.config.max_value) + update_runtime_gauge(tag, markers=tuple(markers)) + + +def set_runtime_show(tag: Tag, show: bool) -> None: + update_runtime_gauge(tag, show=show) + + +def delete_runtime_gauge(tag: Tag) -> None: + get_gauge_state(tag) + del _registry[tag] + + +def update_state_size(state: GaugeState, size: SizeState) -> None: + state.measured_width = size.measured_width + state.measured_height = size.measured_height + state.last_nonzero_width = size.last_nonzero_width + state.last_nonzero_height = size.last_nonzero_height + state.dirty |= DirtyFlags.SIZE | DirtyFlags.STATIC | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + + +def size_state_for_gauge(state: GaugeState) -> SizeState: + return SizeState( + requested=RequestedSize( + state.config.width, + state.config.height, + state.config.autosize_x, + state.config.autosize_y, + ), + measured_width=state.measured_width, + measured_height=state.measured_height, + last_nonzero_width=state.last_nonzero_width, + last_nonzero_height=state.last_nonzero_height, + ) diff --git a/src/dpg_gauges/themes.py b/src/dpg_gauges/themes.py new file mode 100644 index 0000000..c4a02c4 --- /dev/null +++ b/src/dpg_gauges/themes.py @@ -0,0 +1,76 @@ +"""Lightweight theme and style presets.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace + +from .exceptions import GaugeConfigError +from .types import Color, validate_color + + +@dataclass(frozen=True) +class GaugeStyle: + background_color: Color = (24, 24, 28, 255) + frame_color: Color = (58, 58, 66, 255) + value_color: Color = (82, 172, 255, 255) + text_color: Color = (235, 235, 240, 255) + muted_text_color: Color = (150, 150, 158, 255) + border_color: Color = (78, 78, 88, 255) + needle_color: Color = (255, 255, 255, 255) + tick_color: Color = (190, 190, 198, 255) + redline_color: Color = (255, 45, 45, 255) + + def __post_init__(self) -> None: + for field_name, color in self.__dict__.items(): + validate_color(color, name=field_name) + + +@dataclass(frozen=True) +class GaugeTheme: + name: str = "default" + style: GaugeStyle = GaugeStyle() + + +_PRESETS: dict[str, GaugeTheme] = { + "default": GaugeTheme(), + "dark": GaugeTheme("dark", GaugeStyle(background_color=(14, 16, 20, 255))), + "minimal": GaugeTheme( + "minimal", + GaugeStyle( + background_color=(0, 0, 0, 0), + frame_color=(210, 210, 216, 255), + value_color=(40, 40, 44, 255), + text_color=(30, 30, 34, 255), + muted_text_color=(110, 110, 116, 255), + border_color=(220, 220, 226, 255), + needle_color=(30, 30, 34, 255), + tick_color=(100, 100, 108, 255), + ), + ), + "motorsport": GaugeTheme( + "motorsport", + GaugeStyle(value_color=(255, 185, 0, 255), needle_color=(255, 35, 35, 255)), + ), + "industrial": GaugeTheme( + "industrial", + GaugeStyle(background_color=(34, 36, 34, 255), value_color=(80, 220, 120, 255)), + ), +} + + +def get_theme(theme: str | GaugeTheme | None = None) -> GaugeTheme: + if theme is None: + return _PRESETS["default"] + if isinstance(theme, GaugeTheme): + return theme + try: + return _PRESETS[theme] + except KeyError as exc: + msg = f"unknown gauge theme: {theme!r}" + raise GaugeConfigError(msg) from exc + + +def merge_style(base: GaugeStyle | None = None, **overrides: Color | None) -> GaugeStyle: + style = base or GaugeStyle() + clean = {key: value for key, value in overrides.items() if value is not None} + return replace(style, **clean) diff --git a/src/dpg_gauges/types.py b/src/dpg_gauges/types.py new file mode 100644 index 0000000..7f6eed8 --- /dev/null +++ b/src/dpg_gauges/types.py @@ -0,0 +1,107 @@ +"""Shared public types and dataclasses.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, TypeAlias + +from .exceptions import GaugeConfigError + +Tag: TypeAlias = str | int +Color: TypeAlias = tuple[int, int, int] | tuple[int, int, int, int] +GaugeValue: TypeAlias = float | int | bool | str | None + + +def validate_color(color: Color, *, name: str = "color") -> Color: + if len(color) not in (3, 4): + msg = f"{name} must contain 3 or 4 components" + raise GaugeConfigError(msg) + if any(component < 0 or component > 255 for component in color): + msg = f"{name} components must be between 0 and 255" + raise GaugeConfigError(msg) + return color + + +@dataclass(frozen=True) +class ThresholdBand: + start: float + end: float + color: Color + label: str | None = None + + def __post_init__(self) -> None: + if self.start > self.end: + msg = "threshold start must be less than or equal to end" + raise GaugeConfigError(msg) + validate_color(self.color, name="threshold color") + + +@dataclass(frozen=True) +class GaugeMarker: + value: float + color: Color + label: str | None = None + thickness: float = 2.0 + + def __post_init__(self) -> None: + if self.thickness <= 0: + msg = "marker thickness must be greater than zero" + raise GaugeConfigError(msg) + validate_color(self.color, name="marker color") + + +@dataclass(frozen=True) +class AnimationConfig: + enabled: bool = True + duration: float = 0.15 + easing: str = "linear" + + def __post_init__(self) -> None: + if self.duration < 0: + msg = "animation duration must be non-negative" + raise GaugeConfigError(msg) + + +@dataclass(frozen=True) +class SmoothingConfig: + enabled: bool = False + alpha: float = 0.35 + max_step: float | None = None + + def __post_init__(self) -> None: + if not 0 < self.alpha <= 1: + msg = "smoothing alpha must be greater than 0 and at most 1" + raise GaugeConfigError(msg) + if self.max_step is not None and self.max_step <= 0: + msg = "smoothing max_step must be greater than zero" + raise GaugeConfigError(msg) + + +@dataclass(frozen=True) +class StatusState: + color: Color + text: str = "" + value_color: Color | None = None + + def __post_init__(self) -> None: + validate_color(self.color, name="status color") + if self.value_color is not None: + validate_color(self.value_color, name="status value color") + + +@dataclass(frozen=True) +class GaugeStats: + min_value: float | None = None + max_value: float | None = None + average: float | None = None + samples: int = 0 + + def __post_init__(self) -> None: + if self.samples < 0: + msg = "stats samples must be non-negative" + raise GaugeConfigError(msg) + + +ValueFormatter: TypeAlias = Callable[[float], str] +Callback: TypeAlias = Callable[[Tag, GaugeValue, Any], None] diff --git a/src/dpg_gauges/widget.py b/src/dpg_gauges/widget.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_step1_core.py b/tests/test_step1_core.py new file mode 100644 index 0000000..a7860f7 --- /dev/null +++ b/tests/test_step1_core.py @@ -0,0 +1,117 @@ +import pytest + +import dpg_gauges as dpgg +from dpg_gauges.exceptions import GaugeConfigError, GaugeNotFoundError, GaugeValueError +from dpg_gauges.gauges import AnalogGaugeConfig, DigitalGaugeConfig +from dpg_gauges.scales import ( + clamp_value, + clamped_threshold, + fill_fraction, + format_gauge_value, + generate_ticks, + normalize_value, + value_to_angle, +) + + +def test_public_api_exports_required_names() -> None: + required = { + "configure", + "GaugeTheme", + "GaugeStyle", + "AnimationConfig", + "SmoothingConfig", + "ThresholdBand", + "GaugeMarker", + "StatusState", + "GaugeValue", + "GaugeStats", + "analog_gauge", + "digital_gauge", + "bar_gauge", + "level_gauge", + "status_light", + "segmented_gauge", + "compass_gauge", + "thermometer_gauge", + "battery_gauge", + "multi_value_gauge", + "gauge_panel", + "gauge_grid", + "set_value", + "get_value", + "update_gauge", + "configure_gauge", + "set_thresholds", + "set_markers", + "set_show", + "delete_gauge", + "get_gauge_debug_state", + } + + assert required.issubset(set(dpgg.__all__)) + for name in required: + assert hasattr(dpgg, name) + + +def test_dataclass_construction_and_context_stub() -> None: + threshold = dpgg.ThresholdBand(10, 20, (255, 0, 0, 255), "hot") + marker = dpgg.GaugeMarker(50, (0, 255, 0), thickness=1.5) + config = AnalogGaugeConfig( + label="RPM", + min_value=0, + max_value=8000, + thresholds=[threshold], + markers=[marker], + animation=True, + smoothing=False, + ) + + assert config.thresholds == (threshold,) + assert config.markers == (marker,) + assert config.animation == dpgg.AnimationConfig(enabled=True) + assert config.smoothing == dpgg.SmoothingConfig(enabled=False) + + with dpgg.digital_gauge(label="Speed", value=12.3) as digital: + assert isinstance(digital, DigitalGaugeConfig) + assert digital.label == "Speed" + + +def test_clamp_normalize_angle_and_fill_math() -> None: + assert clamp_value(150, 0, 100) == 100 + assert normalize_value(25, 0, 100) == 0.25 + assert fill_fraction(-10, 0, 100) == 0 + assert value_to_angle(50, 0, 100, 225, -45) == 90 + + with pytest.raises(GaugeValueError): + clamp_value(float("nan"), 0, 100) + + +def test_tick_generation_and_formatting() -> None: + assert generate_ticks(0, 100, 5) == [0, 25, 50, 75, 100] + assert format_gauge_value(12.345, precision=1, unit="V") == "12.3 V" + assert format_gauge_value(float("inf"), precision=1) == "--" + + with pytest.raises(GaugeConfigError): + generate_ticks(0, 100, 1) + + +def test_threshold_validation_and_clamping() -> None: + threshold = dpgg.ThresholdBand(-10, 120, (255, 128, 0, 255)) + assert clamped_threshold(threshold, 0, 100) == (0, 100) + + with pytest.raises(GaugeConfigError): + dpgg.ThresholdBand(20, 10, (255, 0, 0)) + + with pytest.raises(GaugeConfigError): + AnalogGaugeConfig(min_value=100, max_value=0) + + +def test_runtime_stubs_import_without_dpg_context() -> None: + dpgg.configure(debug=True) + + with pytest.raises(GaugeNotFoundError): + dpgg.set_value("missing", 1) + + with pytest.raises(GaugeNotFoundError): + dpgg.get_gauge_debug_state("missing") diff --git a/tests/test_step2_state_model.py b/tests/test_step2_state_model.py new file mode 100644 index 0000000..58061ce --- /dev/null +++ b/tests/test_step2_state_model.py @@ -0,0 +1,162 @@ +import pytest + +import dpg_gauges as dpgg +from dpg_gauges.animation import advance_animation, interpolate, start_animation +from dpg_gauges.exceptions import GaugeConfigError, GaugeNotFoundError +from dpg_gauges.gauges import DigitalGaugeConfig +from dpg_gauges.sizing import ( + SizeState, + effective_size, + requested_size, + square_gauge_rect, + stretch_gauge_rect, + update_measured_size, +) +from dpg_gauges.smoothing import SmoothingState, advance_smoothing, smooth_value +from dpg_gauges.state import DirtyFlags, clear_registry, get_gauge_state, register_gauge +from dpg_gauges.types import AnimationConfig, SmoothingConfig + + +@pytest.fixture(autouse=True) +def clean_registry() -> None: + clear_registry() + dpgg.configure() + + +def test_registry_register_get_and_delete() -> None: + with dpgg.digital_gauge(tag="speed", value=42, min_value=0, max_value=100) as config: + assert config.tag == "speed" + + assert dpgg.get_value("speed") == 42 + state = get_gauge_state("speed") + assert state.gauge_type == "digital" + assert state.clamped_value == 42 + assert state.displayed_value == 42 + + with pytest.raises(GaugeConfigError): + register_gauge("digital", DigitalGaugeConfig(tag="speed")) + + dpgg.delete_gauge("speed") + with pytest.raises(GaugeNotFoundError): + dpgg.get_value("speed") + + +def test_value_clamped_displayed_and_dirty_flags_are_separate() -> None: + dpgg.digital_gauge(tag="rpm", value=10, min_value=0, max_value=100, animation=True) + state = get_gauge_state("rpm") + state.dirty = DirtyFlags.NONE + + dpgg.set_value("rpm", 150) + + assert dpgg.get_value("rpm") == 150 + assert state.clamped_value == 100 + assert state.displayed_value == 10 + assert state.animation_state.active + assert state.dirty & DirtyFlags.VALUE + assert state.dirty & DirtyFlags.DYNAMIC + assert state.dirty & DirtyFlags.TEXT + + +def test_update_gauge_marks_static_text_size_and_style_dirty() -> None: + dpgg.digital_gauge(tag="temp", value=20, min_value=0, max_value=100) + state = get_gauge_state("temp") + state.dirty = DirtyFlags.NONE + + dpgg.update_gauge( + "temp", + label="Temperature", + unit="C", + min_value=-40, + max_value=140, + width=200, + style=state.config.style, + ) + + assert state.config.label == "Temperature" + assert state.config.unit == "C" + assert state.config.min_value == -40 + assert state.dirty & DirtyFlags.STATIC + assert state.dirty & DirtyFlags.DYNAMIC + assert state.dirty & DirtyFlags.TEXT + assert state.dirty & DirtyFlags.SIZE + assert state.dirty & DirtyFlags.STYLE + + +def test_threshold_marker_and_show_runtime_updates() -> None: + dpgg.digital_gauge(tag="fuel", min_value=0, max_value=100) + threshold = dpgg.ThresholdBand(0, 15, (255, 0, 0)) + marker = dpgg.GaugeMarker(50, (0, 255, 0)) + + dpgg.set_thresholds("fuel", [threshold]) + dpgg.set_markers("fuel", [marker]) + dpgg.set_show("fuel", False) + + state = get_gauge_state("fuel") + assert state.config.thresholds == (threshold,) + assert state.config.markers == (marker,) + assert state.config.show is False + assert state.dirty & DirtyFlags.STATIC + assert state.dirty & DirtyFlags.SIZE + + +def test_non_finite_values_do_not_crash_state_helpers() -> None: + dpgg.digital_gauge(tag="boost", min_value=0, max_value=30) + + dpgg.set_value("boost", float("nan")) + state = get_gauge_state("boost") + + assert state.target_value != state.target_value + assert state.clamped_value is None + assert state.displayed_value is None + assert state.invalid_value is True + + +def test_sizing_preserves_last_nonzero_size_and_rects() -> None: + size = SizeState(requested=requested_size(100, 80)) + + assert update_measured_size(size, 320, 180) is True + assert effective_size(size) == (320, 180) + + update_measured_size(size, 0, 0) + assert effective_size(size) == (320, 180) + assert square_gauge_rect(320, 180).width == 180 + assert square_gauge_rect(320, 180).x == 70 + assert stretch_gauge_rect(320, 180).width == 320 + assert stretch_gauge_rect(320, 180).height == 180 + + +def test_animation_interpolation_and_completion() -> None: + config = AnimationConfig(enabled=True, duration=1.0) + state = start_animation(0, 100, config, now=10.0) + + assert interpolate(0, 100, 0.25) == 25 + value, active = advance_animation(state, config, now=10.5) + assert value == 50 + assert active is True + + value, active = advance_animation(state, config, now=11.0) + assert value == 100 + assert active is False + + +def test_smoothing_progression() -> None: + config = SmoothingConfig(enabled=True, alpha=0.5, max_step=10) + + assert smooth_value(None, 100, config) == (100, False) + + state = SmoothingState(value=0) + value, active = advance_smoothing(state, 100, config) + assert value == 10 + assert active is True + + +def test_debug_state_reports_model_fields() -> None: + dpgg.digital_gauge(tag="debug", value=5, width=120, height=30) + + debug = dpgg.get_gauge_debug_state("debug") + + assert debug["gauge_type"] == "digital" + assert debug["target_value"] == 5 + assert debug["clamped_value"] == 5 + assert debug["measured_size"] == (120, 30) + assert "VALUE" in debug["dirty_names"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..5ce68d0 --- /dev/null +++ b/uv.lock @@ -0,0 +1,168 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dearpygui" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/40/22c00a3b3e47c609ece39b0e1970b8e031ac613201fee065dd93c9cbcb6c/dearpygui-2.3.1-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:e1a9ffbb845600ab4a171daf2d7e7a1895f5b4433eed248d4cab42766b55fdff", size = 2002951, upload-time = "2026-05-01T22:45:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/31/7d/b2b26e75437a6173e44a89a816d5f1df76537ca2f4b3206a2a787ac9d479/dearpygui-2.3.1-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:686423b4f8a30aaf57f1044ec4e3b087beb9cc9b25be812cf92f4e7feff44fd6", size = 2678639, upload-time = "2026-05-01T22:46:06.246Z" }, + { url = "https://files.pythonhosted.org/packages/00/90/efaa3dbcdab267638b0b65551c88ea1e994235b74345c8d995b8a0a9d59e/dearpygui-2.3.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:0259a2db7b6c82459ac1402b0d42388a7cd180cd97d7dcb31a5210725c3171d3", size = 2501986, upload-time = "2026-05-01T22:46:17.044Z" }, + { url = "https://files.pythonhosted.org/packages/b4/db/8f15dfe06d104746591c4763a5bba31369b86d861aa916101da8f633bfc6/dearpygui-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:76ee38696a8f1f3805751b1588c98d9bb6567b033b91cb0f58111fb9fe907b5b", size = 1881816, upload-time = "2026-05-01T22:45:48.044Z" }, + { url = "https://files.pythonhosted.org/packages/20/f1/6f30c7eafa25755105d0c26049a95c89cc5c82b68971e1cc8ffea36b748a/dearpygui-2.3.1-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:6b1efd675a358176b3b76c039587a543d407d8fbb2cc3643a527e0ab7c0f5577", size = 2002717, upload-time = "2026-05-01T22:46:00.242Z" }, + { url = "https://files.pythonhosted.org/packages/cc/03/fa76c31087b6ebbc13948de6d9d3bc9ed9946824b13d9d1089356b54249c/dearpygui-2.3.1-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:364b5b6a0953999fec764d529d9bb0b2cc6ab6ed06188e0ded28237c1475ca98", size = 2678552, upload-time = "2026-05-01T22:46:07.502Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ef/657ec68a79664cd28d98d590eed85103c6ffc1e23e7d3c4e5aedb7ee887d/dearpygui-2.3.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:199dffebda245c9f59aa7c10c3b7f62963237c5f744150ad9be409b27a2661d3", size = 2501224, upload-time = "2026-05-01T22:46:18.8Z" }, + { url = "https://files.pythonhosted.org/packages/77/be/e67e5a04439f15e60cf596092a31b2a907272288954e47f5ced43c0b1678/dearpygui-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:26ed9197cdb30099c665bd1b23724e0dba60ad06b71344ecca7e52748c21874b", size = 1882080, upload-time = "2026-05-01T22:45:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/56a293ad392b8dafdd3eb1a0c4d367efbde99aa6930359685fe270984dd1/dearpygui-2.3.1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:d2d29e72b031886893b32269e1478eff1fd651512e6c2bceda91059c3bc8ee0e", size = 2002694, upload-time = "2026-05-01T22:46:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e0/df598671f40a7fffb8390946115d9e9cf12d3f4a52f5b9381bddc5699aee/dearpygui-2.3.1-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:e3f64c8d4cae68a3b5be1b7531d08400ccbf1e4ff03c7acfc85954f98993f864", size = 2678564, upload-time = "2026-05-01T22:46:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d1/87cfb577d4a1615e809e2f6e0fd720bd4ad6cd25e365ef2fd08a6e09c86e/dearpygui-2.3.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:0d8b7a5a04cd4a6e25dbf162aa0405cf12143a1c82b33e371c20e5ba8d9349f3", size = 2503196, upload-time = "2026-05-01T22:46:20.276Z" }, + { url = "https://files.pythonhosted.org/packages/b3/38/05f74181a59353bdc927b6ec46333714f633c5755f3d4bcda34d6eb100a5/dearpygui-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:7946722c50f1e08866dfdb78eb33dd4e67dea1a3cfe4400b159e1c96861116dc", size = 1882082, upload-time = "2026-05-01T22:45:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/4a/08/68ce3ba941cf09bcf288f2603d7178c936d473172fd545e0665b885cb840/dearpygui-2.3.1-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:e2cbabd8d383308ea3520360e100d1e95c9a957cd26247431cd4920b23c3b56e", size = 2003034, upload-time = "2026-05-01T22:46:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3f/ac003b6d636c9f5aae910a62d00b6c119403212cf2d7772d7c59ca8ef6dd/dearpygui-2.3.1-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:953442c7272e57f3686e393edc5cf6ce73cc2f47142739735dfabed403e33770", size = 2678545, upload-time = "2026-05-01T22:46:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6b/657a2f1e2f604f356c39ed5b870aaed5d07fdeac3139fae2a455473b64ca/dearpygui-2.3.1-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:43a561b5dc589944a3a2b469e5e68f1ab35ae38b3dcdf1f813ed9d6e024153f8", size = 2501699, upload-time = "2026-05-01T22:46:21.497Z" }, + { url = "https://files.pythonhosted.org/packages/8c/56/93b2310891589063e0226cb07fe05e08fbdb3f409f80f9edefb1cebea84e/dearpygui-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:272cc52c66bc5a4a332f13a3af5e603070d47ca7cc4976e6a8c247594e2b3dd0", size = 1943067, upload-time = "2026-05-01T22:45:52.255Z" }, +] + +[[package]] +name = "dpg-gauges" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "dearpygui" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "dearpygui", specifier = ">=2.3.1" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.15.20" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From 41e44a23bcbe4d5ca0db192446dfb587a0afa8ab Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Thu, 2 Jul 2026 14:20:22 +0200 Subject: [PATCH 2/8] step 3: add widget shell and renderer foundation --- README.md | 9 +- codex/AGENTS.md | 30 +++- examples/basic_analog.py | 25 +++ examples/basic_digital.py | 25 +++ examples/dashboard.py | 35 +++++ examples/sizing.py | 25 +++ src/dpg_gauges/api.py | 87 ++--------- src/dpg_gauges/diagnostics.py | 5 +- src/dpg_gauges/draw_layers.py | 44 ++++++ src/dpg_gauges/renderer.py | 166 ++++++++++++++++++++ src/dpg_gauges/state.py | 11 +- src/dpg_gauges/widget.py | 227 ++++++++++++++++++++++++++++ tests/test_step3_widget_renderer.py | 78 ++++++++++ 13 files changed, 683 insertions(+), 84 deletions(-) create mode 100644 examples/basic_analog.py create mode 100644 examples/basic_digital.py create mode 100644 examples/dashboard.py create mode 100644 examples/sizing.py create mode 100644 tests/test_step3_widget_renderer.py diff --git a/README.md b/README.md index e19716f..d05ad0c 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ Dear PyGui gauge widgets for dashboards and telemetry displays. -Current implementation status: Step 2 logical state is implemented. Gauge creation functions build -and register pure gauge state without requiring a Dear PyGui context; final widget shells and drawing -arrive in later steps. +Current implementation status: Step 3 widget shells are implemented. Gauge creation functions still +register pure gauge state without requiring a Dear PyGui context, and when a Dear PyGui context is +active they create a group plus drawlist shell with placeholder frame and value text rendering. -Runtime value updates are intended for the Dear PyGui GUI thread once rendering is implemented. +Runtime value updates are intended for the Dear PyGui GUI thread. Attached gauges redraw immediately +for simple value/configuration changes. diff --git a/codex/AGENTS.md b/codex/AGENTS.md index bc0d087..0262e93 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -2,7 +2,7 @@ ## Current status -Step 2 completed and verified. Ready to start Step 3. +Step 3 completed and verified with automated checks. Ready to start Step 4. ## Completed steps @@ -28,7 +28,18 @@ Step 1 - Public API contract and pure core: ## Current step -Step 3 - Widget shell and renderer foundation. +Step 4 - Digital, bar, level, and status gauges. + +Step 3 - Widget shell and renderer foundation: + +Implemented during Step 3: + +- Added Dear PyGui widget shell creation for gauge context managers when a DPG context is active. +- Kept pure registration behavior when no DPG context has been created. +- Added a renderer foundation that measures drawlist size, redraws stable placeholder background/frame/value text, schedules animation frames, and exposes renderer debug fields. +- Added draw layer bookkeeping. +- Added lightweight `gauge_panel` and `gauge_grid` containers. +- Added Step 3 examples and smoke tests. Step 2 - State, sizing, animation, and smoothing model: @@ -56,9 +67,9 @@ Implemented during Step 2: - The public API should feel like normal Dear PyGui context-manager usage. - Each completed build step must be checked, documented in this file, and committed before starting the next step. -## Known issues +## Known issues -None yet. +Manual visual example checks were not run in this headless session. ## Commands used @@ -84,7 +95,14 @@ None yet. - Ran `uv run ruff format --check .`. - Ran `uv run pyright`. - Updated instructions to require a commit after each completed step. +- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 3 changes. +- Implemented Step 3 widget shells, renderer foundation, draw layer bookkeeping, layout helpers, examples, smoke tests, README status update, and diagnostics renderer fields. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. -## Next action +## Next action -Implement Step 3. +Implement Step 4. diff --git a/examples/basic_analog.py b/examples/basic_analog.py new file mode 100644 index 0000000..e0e6bab --- /dev/null +++ b/examples/basic_analog.py @@ -0,0 +1,25 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + with dpg.window(label="Analog Gauge", width=320, height=320): + dpgg.analog_gauge( + tag="rpm", label="RPM", value=3250, max_value=8000, width=240, height=240 + ) + + dpg.create_viewport(title="dpg-gauges analog", width=360, height=360) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/basic_digital.py b/examples/basic_digital.py new file mode 100644 index 0000000..8f0752f --- /dev/null +++ b/examples/basic_digital.py @@ -0,0 +1,25 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + with dpg.window(label="Digital Gauge", width=320, height=180): + dpgg.digital_gauge( + tag="speed", label="Speed", value=72, unit="km/h", width=260, height=100 + ) + + dpg.create_viewport(title="dpg-gauges digital", width=360, height=220) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/dashboard.py b/examples/dashboard.py new file mode 100644 index 0000000..cbff3d4 --- /dev/null +++ b/examples/dashboard.py @@ -0,0 +1,35 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + with ( + dpg.window(label="Dashboard", width=620, height=360), + dpgg.gauge_panel(label="Engine", width=580, height=260), + dpgg.gauge_grid(columns=3), + ): + dpgg.analog_gauge( + tag="rpm", label="RPM", value=4200, max_value=8000, width=180, height=180 + ) + dpgg.digital_gauge( + tag="speed", label="Speed", value=88, unit="km/h", width=160, height=90 + ) + dpgg.digital_gauge( + tag="temp", label="Coolant", value=91, unit="C", width=160, height=90 + ) + + dpg.create_viewport(title="dpg-gauges dashboard", width=660, height=400) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/sizing.py b/examples/sizing.py new file mode 100644 index 0000000..f7ee2ba --- /dev/null +++ b/examples/sizing.py @@ -0,0 +1,25 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + with dpg.window(label="Sizing", width=520, height=300): + dpgg.digital_gauge(tag="fixed", label="Fixed", value=12.3, width=180, height=80) + dpgg.digital_gauge(tag="wide", label="Fill width", value=45.6, width=-1, height=80) + dpgg.analog_gauge(tag="square", label="Square", value=60, width=180, height=180) + + dpg.create_viewport(title="dpg-gauges sizing", width=560, height=340) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/src/dpg_gauges/api.py b/src/dpg_gauges/api.py index 8989b25..ac513be 100644 --- a/src/dpg_gauges/api.py +++ b/src/dpg_gauges/api.py @@ -2,27 +2,14 @@ from __future__ import annotations -from contextlib import nullcontext -from typing import Any, cast +from typing import Any -from .gauges import ( - AnalogGaugeConfig, - BarGaugeConfig, - BatteryGaugeConfig, - CompassGaugeConfig, - DigitalGaugeConfig, - GaugeConfig, - LevelGaugeConfig, - MultiValueGaugeConfig, - SegmentedGaugeConfig, - StatusLightConfig, - ThermometerGaugeConfig, -) +from . import widget as _widget +from .gauges import GaugeConfig from .state import ( configure_gauges, delete_runtime_gauge, get_runtime_value, - register_gauge, set_runtime_markers, set_runtime_show, set_runtime_thresholds, @@ -36,62 +23,18 @@ def configure(**config: Any) -> None: configure_gauges(**config) -def analog_gauge(**config: Any) -> nullcontext[AnalogGaugeConfig]: - state = register_gauge("analog", AnalogGaugeConfig(**config)) - return nullcontext(cast(AnalogGaugeConfig, state.config)) - - -def digital_gauge(**config: Any) -> nullcontext[DigitalGaugeConfig]: - state = register_gauge("digital", DigitalGaugeConfig(**config)) - return nullcontext(cast(DigitalGaugeConfig, state.config)) - - -def bar_gauge(**config: Any) -> nullcontext[BarGaugeConfig]: - state = register_gauge("bar", BarGaugeConfig(**config)) - return nullcontext(cast(BarGaugeConfig, state.config)) - - -def level_gauge(**config: Any) -> nullcontext[LevelGaugeConfig]: - state = register_gauge("level", LevelGaugeConfig(**config)) - return nullcontext(cast(LevelGaugeConfig, state.config)) - - -def status_light(**config: Any) -> nullcontext[StatusLightConfig]: - state = register_gauge("status_light", StatusLightConfig(**config)) - return nullcontext(cast(StatusLightConfig, state.config)) - - -def segmented_gauge(**config: Any) -> nullcontext[SegmentedGaugeConfig]: - state = register_gauge("segmented", SegmentedGaugeConfig(**config)) - return nullcontext(cast(SegmentedGaugeConfig, state.config)) - - -def compass_gauge(**config: Any) -> nullcontext[CompassGaugeConfig]: - state = register_gauge("compass", CompassGaugeConfig(**config)) - return nullcontext(cast(CompassGaugeConfig, state.config)) - - -def thermometer_gauge(**config: Any) -> nullcontext[ThermometerGaugeConfig]: - state = register_gauge("thermometer", ThermometerGaugeConfig(**config)) - return nullcontext(cast(ThermometerGaugeConfig, state.config)) - - -def battery_gauge(**config: Any) -> nullcontext[BatteryGaugeConfig]: - state = register_gauge("battery", BatteryGaugeConfig(**config)) - return nullcontext(cast(BatteryGaugeConfig, state.config)) - - -def multi_value_gauge(**config: Any) -> nullcontext[MultiValueGaugeConfig]: - state = register_gauge("multi_value", MultiValueGaugeConfig(**config)) - return nullcontext(cast(MultiValueGaugeConfig, state.config)) - - -def gauge_panel(**_config: Any) -> nullcontext[None]: - return nullcontext(None) - - -def gauge_grid(**_config: Any) -> nullcontext[None]: - return nullcontext(None) +analog_gauge = _widget.analog_gauge +digital_gauge = _widget.digital_gauge +bar_gauge = _widget.bar_gauge +level_gauge = _widget.level_gauge +status_light = _widget.status_light +segmented_gauge = _widget.segmented_gauge +compass_gauge = _widget.compass_gauge +thermometer_gauge = _widget.thermometer_gauge +battery_gauge = _widget.battery_gauge +multi_value_gauge = _widget.multi_value_gauge +gauge_panel = _widget.gauge_panel +gauge_grid = _widget.gauge_grid def set_value(tag: Tag, value: GaugeValue) -> None: diff --git a/src/dpg_gauges/diagnostics.py b/src/dpg_gauges/diagnostics.py index d9326aa..14d1c19 100644 --- a/src/dpg_gauges/diagnostics.py +++ b/src/dpg_gauges/diagnostics.py @@ -10,7 +10,7 @@ from .types import Tag def get_gauge_debug_state(tag: Tag) -> dict[str, Any]: state = get_gauge_state(tag) - return { + debug = { "tag": state.tag, "gauge_type": state.gauge_type, "target_value": state.target_value, @@ -26,3 +26,6 @@ def get_gauge_debug_state(tag: Tag) -> dict[str, Any]: "animation_active": state.animation_state.active, "smoothing_active": state.smoothing_state.active, } + if state.renderer is not None: + debug["renderer"] = state.renderer.debug_state() + return debug diff --git a/src/dpg_gauges/draw_layers.py b/src/dpg_gauges/draw_layers.py index e69de29..a1ee462 100644 --- a/src/dpg_gauges/draw_layers.py +++ b/src/dpg_gauges/draw_layers.py @@ -0,0 +1,44 @@ +"""Bookkeeping for Dear PyGui drawlist layers.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .types import Tag + + +@dataclass +class DrawLayer: + name: str + item_tags: set[Tag] = field(default_factory=set) + + def add(self, tag: Tag) -> Tag: + self.item_tags.add(tag) + return tag + + def clear(self) -> None: + self.item_tags.clear() + + +@dataclass +class DrawLayers: + background: DrawLayer = field(default_factory=lambda: DrawLayer("background")) + static: DrawLayer = field(default_factory=lambda: DrawLayer("static")) + thresholds: DrawLayer = field(default_factory=lambda: DrawLayer("thresholds")) + dynamic: DrawLayer = field(default_factory=lambda: DrawLayer("dynamic")) + markers: DrawLayer = field(default_factory=lambda: DrawLayer("markers")) + text: DrawLayer = field(default_factory=lambda: DrawLayer("text")) + + def all_layers(self) -> tuple[DrawLayer, ...]: + return ( + self.background, + self.static, + self.thresholds, + self.dynamic, + self.markers, + self.text, + ) + + def clear(self) -> None: + for layer in self.all_layers(): + layer.clear() diff --git a/src/dpg_gauges/renderer.py b/src/dpg_gauges/renderer.py index e69de29..c267245 100644 --- a/src/dpg_gauges/renderer.py +++ b/src/dpg_gauges/renderer.py @@ -0,0 +1,166 @@ +"""Dear PyGui renderer foundation for gauge placeholders.""" + +from __future__ import annotations + +import time +from typing import Any + +import dearpygui.dearpygui as dpg + +from .animation import advance_animation +from .draw_layers import DrawLayers +from .scales import format_gauge_value +from .sizing import effective_size, update_measured_size +from .smoothing import advance_smoothing +from .state import DirtyFlags, GaugeState, animation_config, size_state_for_gauge, update_state_size +from .themes import GaugeStyle +from .types import SmoothingConfig + + +class GaugeRenderer: + """Small renderer that keeps a gauge drawlist populated between updates.""" + + def __init__(self, state: GaugeState) -> None: + self.state = state + self.layers = DrawLayers() + self._scheduled = False + + def render(self) -> None: + state = self.state + self._measure() + self._advance_values() + self._redraw_placeholder() + state.dirty = DirtyFlags.NONE + if state.animation_state.active or state.smoothing_state.active: + self.schedule_frame() + + def delete(self) -> None: + if dpg.does_item_exist(self.state.container_tag): + dpg.delete_item(self.state.container_tag) + + def schedule_frame(self) -> None: + if self._scheduled: + return + self._scheduled = True + frame = dpg.get_frame_count() + 1 + dpg.set_frame_callback(frame, self._frame_callback) + + def debug_state(self) -> dict[str, Any]: + state = self.state + return { + "container_tag": state.container_tag, + "drawlist_tag": state.drawlist_tag, + "layers": {layer.name: tuple(layer.item_tags) for layer in self.layers.all_layers()}, + "scheduled": self._scheduled, + } + + def _frame_callback(self) -> None: + self._scheduled = False + self.state.dirty |= DirtyFlags.DYNAMIC | DirtyFlags.TEXT + self.render() + + def _measure(self) -> None: + state = self.state + if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag): + return + + width = dpg.get_item_width(state.drawlist_tag) or 0 + height = dpg.get_item_height(state.drawlist_tag) or 0 + size = size_state_for_gauge(state) + if update_measured_size(size, width, height): + update_state_size(state, size) + + def _advance_values(self) -> None: + state = self.state + now = time.monotonic() + config = animation_config(state.config) + 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) + 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 + ) + + def _redraw_placeholder(self) -> None: + state = self.state + if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag): + return + + width, height = effective_size(size_state_for_gauge(state)) + width = width or max(state.config.width, 1) or 180 + height = height or max(state.config.height, 1) or 80 + dpg.configure_item(state.drawlist_tag, width=width, height=height, show=state.config.show) + dpg.configure_item(state.container_tag, show=state.config.show) + dpg.delete_item(state.drawlist_tag, children_only=True) + self.layers.clear() + + style = state.config.style or GaugeStyle() + background = self.layers.background.add(f"{state.tag}__background") + frame = self.layers.static.add(f"{state.tag}__frame") + dpg.draw_rectangle( + (0, 0), + (width, height), + parent=state.drawlist_tag, + tag=background, + color=style.border_color, + fill=style.background_color, + rounding=6, + thickness=1, + ) + dpg.draw_rectangle( + (4, 4), + (max(width - 4, 4), max(height - 4, 4)), + parent=state.drawlist_tag, + tag=frame, + color=style.frame_color, + rounding=4, + thickness=1, + ) + + label_y = 10 + if state.config.show_label and state.config.label: + label = self.layers.text.add(f"{state.tag}__label") + dpg.draw_text( + (12, label_y), + state.config.label, + parent=state.drawlist_tag, + tag=label, + color=style.muted_text_color, + size=14, + ) + + value = self._display_text() + value_tag = self.layers.dynamic.add(f"{state.tag}__value") + dpg.draw_text( + (12, max(height * 0.45 - 10, 24)), + value, + parent=state.drawlist_tag, + tag=value_tag, + color=style.value_color if not state.invalid_value else style.redline_color, + size=24, + ) + + def _display_text(self) -> str: + state = self.state + if not state.config.show_value: + return "" + if state.invalid_value or state.displayed_value is None: + return "--" + if state.config.format_value is not None: + return state.config.format_value(state.displayed_value) + unit = state.config.unit if state.config.show_unit else "" + return format_gauge_value(state.displayed_value, state.config.precision, unit) + + +def render_attached_gauge(state: GaugeState) -> None: + if state.renderer is not 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 diff --git a/src/dpg_gauges/state.py b/src/dpg_gauges/state.py index f62bb18..f9be7e4 100644 --- a/src/dpg_gauges/state.py +++ b/src/dpg_gauges/state.py @@ -216,6 +216,7 @@ def set_runtime_value(tag: Tag, value: GaugeValue, *, now: float = 0.0) -> None: state.displayed_value = clamped state.smoothing_state.value = clamped state.dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + render_runtime_gauge(state) def get_runtime_value(tag: Tag) -> GaugeValue: @@ -257,6 +258,7 @@ def update_runtime_gauge(tag: Tag, **config: Any) -> None: dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT state.dirty |= dirty + render_runtime_gauge(state) def set_runtime_thresholds( @@ -278,10 +280,17 @@ def set_runtime_show(tag: Tag, show: bool) -> None: def delete_runtime_gauge(tag: Tag) -> None: - get_gauge_state(tag) + state = get_gauge_state(tag) + if state.renderer is not None: + state.renderer.delete() del _registry[tag] +def render_runtime_gauge(state: GaugeState) -> None: + if state.renderer is not None: + state.renderer.render() + + def update_state_size(state: GaugeState, size: SizeState) -> None: state.measured_width = size.measured_width state.measured_height = size.measured_height diff --git a/src/dpg_gauges/widget.py b/src/dpg_gauges/widget.py index e69de29..7043ae0 100644 --- a/src/dpg_gauges/widget.py +++ b/src/dpg_gauges/widget.py @@ -0,0 +1,227 @@ +"""Dear PyGui widget shell creation helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import TracebackType +from typing import Any, Generic, TypeVar, cast + +import dearpygui.dearpygui as dpg + +from .gauges import ( + AnalogGaugeConfig, + BarGaugeConfig, + BatteryGaugeConfig, + CompassGaugeConfig, + DigitalGaugeConfig, + GaugeConfig, + LevelGaugeConfig, + MultiValueGaugeConfig, + SegmentedGaugeConfig, + StatusLightConfig, + ThermometerGaugeConfig, +) +from .renderer import GaugeRenderer +from .state import GaugeState, register_gauge +from .types import Tag + +ConfigT = TypeVar("ConfigT", bound=GaugeConfig) + +_dpg_context_active = False +_original_create_context = dpg.create_context +_original_destroy_context = dpg.destroy_context + + +def _install_context_tracking() -> None: + if getattr(dpg.create_context, "__dpg_gauges_wrapped__", False): + return + + def create_context(*args: Any, **kwargs: Any) -> Any: + global _dpg_context_active + result = _original_create_context(*args, **kwargs) + _dpg_context_active = True + return result + + def destroy_context(*args: Any, **kwargs: Any) -> Any: + global _dpg_context_active + try: + return _original_destroy_context(*args, **kwargs) + finally: + _dpg_context_active = False + + create_context.__dpg_gauges_wrapped__ = True # type: ignore[attr-defined] + destroy_context.__dpg_gauges_wrapped__ = True # type: ignore[attr-defined] + dpg.create_context = create_context + dpg.destroy_context = destroy_context + + +_install_context_tracking() + + +@dataclass +class GaugeContext(Generic[ConfigT]): + state: GaugeState + + def __enter__(self) -> ConfigT: + if _dpg_context_active and dpg.does_item_exist(self.state.container_tag): + dpg.push_container_stack(self.state.container_tag) + return cast(ConfigT, self.state.config) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if _dpg_context_active and dpg.does_item_exist(self.state.container_tag): + dpg.pop_container_stack() + + +@dataclass +class LayoutContext: + tag: Tag | None + + def __enter__(self) -> Tag | None: + if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag): + dpg.push_container_stack(self.tag) + return self.tag + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag): + dpg.pop_container_stack() + + +def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[ConfigT]: + if not _dpg_context_active: + return GaugeContext(register_gauge(gauge_type, config)) + + tag = config.tag + container_tag = tag if tag is not None else None + drawlist_tag = f"{tag}__drawlist" if tag is not None else None + width = config.width + height = config.height or _default_height(gauge_type) + if height == -1: + height = _default_height(gauge_type) + + container = dpg.add_group( + tag=container_tag or 0, + width=width, + height=height, + show=config.show, + user_data=config.user_data, + ) + drawlist = dpg.add_drawlist( + max(width, 1) if width > 0 else _default_width(gauge_type), + max(height, 1), + tag=drawlist_tag or 0, + parent=container, + show=config.show, + ) + _add_tooltip(container, config.tooltip) + state = register_gauge( + gauge_type, + config, + container_tag=container, + drawlist_tag=drawlist, + text_tags={}, + ) + renderer = GaugeRenderer(state) + state.renderer = renderer + renderer.render() + return GaugeContext(state) + + +def gauge_panel(**config: Any) -> LayoutContext: + if not _dpg_context_active: + return LayoutContext(None) + tag = config.pop("tag", 0) + label = config.pop("label", None) + tooltip = config.pop("tooltip", None) + container = dpg.add_child_window( + tag=tag, + label=label, + width=config.pop("width", 0), + height=config.pop("height", 0), + show=config.pop("show", True), + border=config.pop("border", True), + autosize_x=config.pop("autosize_x", False), + autosize_y=config.pop("autosize_y", False), + **config, + ) + _add_tooltip(container, tooltip) + return LayoutContext(container) + + +def gauge_grid(**config: Any) -> LayoutContext: + if not _dpg_context_active: + return LayoutContext(None) + columns = max(int(config.pop("columns", 1)), 1) + container = dpg.add_group( + tag=config.pop("tag", 0), + width=config.pop("width", 0), + height=config.pop("height", 0), + show=config.pop("show", True), + horizontal=columns > 1, + **config, + ) + return LayoutContext(container) + + +def analog_gauge(**config: Any) -> GaugeContext[AnalogGaugeConfig]: + return create_gauge_context("analog", AnalogGaugeConfig(**config)) + + +def digital_gauge(**config: Any) -> GaugeContext[DigitalGaugeConfig]: + return create_gauge_context("digital", DigitalGaugeConfig(**config)) + + +def bar_gauge(**config: Any) -> GaugeContext[BarGaugeConfig]: + return create_gauge_context("bar", BarGaugeConfig(**config)) + + +def level_gauge(**config: Any) -> GaugeContext[LevelGaugeConfig]: + return create_gauge_context("level", LevelGaugeConfig(**config)) + + +def status_light(**config: Any) -> GaugeContext[StatusLightConfig]: + return create_gauge_context("status_light", StatusLightConfig(**config)) + + +def segmented_gauge(**config: Any) -> GaugeContext[SegmentedGaugeConfig]: + return create_gauge_context("segmented", SegmentedGaugeConfig(**config)) + + +def compass_gauge(**config: Any) -> GaugeContext[CompassGaugeConfig]: + return create_gauge_context("compass", CompassGaugeConfig(**config)) + + +def thermometer_gauge(**config: Any) -> GaugeContext[ThermometerGaugeConfig]: + return create_gauge_context("thermometer", ThermometerGaugeConfig(**config)) + + +def battery_gauge(**config: Any) -> GaugeContext[BatteryGaugeConfig]: + return create_gauge_context("battery", BatteryGaugeConfig(**config)) + + +def multi_value_gauge(**config: Any) -> GaugeContext[MultiValueGaugeConfig]: + return create_gauge_context("multi_value", MultiValueGaugeConfig(**config)) + + +def _default_width(gauge_type: str) -> int: + return 220 if gauge_type in {"analog", "compass"} else 180 + + +def _default_height(gauge_type: str) -> int: + return 220 if gauge_type in {"analog", "compass"} else 80 + + +def _add_tooltip(parent: Tag, tooltip: str | None) -> None: + if not tooltip: + return + tooltip_tag = dpg.add_tooltip(parent) + dpg.add_text(tooltip, parent=tooltip_tag) diff --git a/tests/test_step3_widget_renderer.py b/tests/test_step3_widget_renderer.py new file mode 100644 index 0000000..e8d8296 --- /dev/null +++ b/tests/test_step3_widget_renderer.py @@ -0,0 +1,78 @@ +# pyright: reportGeneralTypeIssues=false + +from collections.abc import Generator + +import dearpygui.dearpygui as dpg +import pytest + +import dpg_gauges as dpgg +from dpg_gauges.state import clear_registry, get_gauge_state + + +@pytest.fixture +def dpg_context() -> Generator[None]: + clear_registry() + dpgg.configure() + dpg.create_context() + try: + yield + finally: + clear_registry() + dpg.destroy_context() + + +def test_dpg_gauge_shell_registers_before_yield(dpg_context: None) -> None: + with ( + dpg.window(label="Smoke"), + dpgg.digital_gauge(tag="speed", label="Speed", value=42, width=180, height=80) as config, + ): + state = get_gauge_state("speed") + assert config.tag == "speed" + assert state.renderer is not None + assert dpg.does_item_exist(state.container_tag) + assert state.drawlist_tag is not None + assert dpg.does_item_exist(state.drawlist_tag) + + +def test_runtime_update_redraws_attached_gauge(dpg_context: None) -> None: + with dpg.window(label="Smoke"): + dpgg.digital_gauge(tag="rpm", label="RPM", value=1000, max_value=8000, width=200, height=90) + + dpgg.set_value("rpm", 2500) + + state = get_gauge_state("rpm") + debug = dpgg.get_gauge_debug_state("rpm") + assert state.displayed_value == 2500 + assert state.dirty == 0 + assert debug["renderer"]["drawlist_tag"] == "rpm__drawlist" + assert "rpm__value" in debug["renderer"]["layers"]["dynamic"] + + +def test_hidden_gauge_preserves_configured_size(dpg_context: None) -> None: + with dpg.window(label="Hidden"): + dpgg.digital_gauge(tag="hidden", value=1, width=160, height=70, show=False) + + state = get_gauge_state("hidden") + assert state.measured_width == 160 + assert state.measured_height == 70 + assert state.last_nonzero_width == 160 + assert state.last_nonzero_height == 70 + + dpgg.set_show("hidden", True) + assert dpg.does_item_exist(state.container_tag) + assert state.config.show is True + + +def test_panel_and_grid_are_lightweight_containers(dpg_context: None) -> None: + with ( + dpg.window(label="Dashboard"), + dpgg.gauge_panel(tag="engine", label="Engine", width=300, height=180) as panel, + dpgg.gauge_grid(tag="engine_grid", columns=2) as grid, + ): + assert panel == "engine" + assert grid == "engine_grid" + dpgg.digital_gauge(tag="temp", label="Temp", value=90, width=120, height=60) + + assert dpg.does_item_exist("engine") + assert dpg.does_item_exist("engine_grid") + assert get_gauge_state("temp").renderer is not None From d638c512e97ae83e74d986d9509fe106ffb9384c Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Thu, 2 Jul 2026 14:35:13 +0200 Subject: [PATCH 3/8] step 4: add digital bar level and status gauges --- README.md | 10 +- codex/AGENTS.md | 22 ++- examples/basic_bar.py | 33 ++++ examples/basic_level.py | 30 ++++ examples/basic_status.py | 35 +++++ examples/thresholds_markers.py | 37 +++++ src/dpg_gauges/renderer.py | 246 ++++++++++++++++++++++++++++-- src/dpg_gauges/scales.py | 24 +++ src/dpg_gauges/state.py | 2 + src/dpg_gauges/widget.py | 10 ++ tests/test_step4_simple_gauges.py | 88 +++++++++++ 11 files changed, 516 insertions(+), 21 deletions(-) create mode 100644 examples/basic_bar.py create mode 100644 examples/basic_level.py create mode 100644 examples/basic_status.py create mode 100644 examples/thresholds_markers.py create mode 100644 tests/test_step4_simple_gauges.py diff --git a/README.md b/README.md index d05ad0c..ace68fd 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,13 @@ Dear PyGui gauge widgets for dashboards and telemetry displays. -Current implementation status: Step 3 widget shells are implemented. Gauge creation functions still -register pure gauge state without requiring a Dear PyGui context, and when a Dear PyGui context is -active they create a group plus drawlist shell with placeholder frame and value text rendering. +Current implementation status: Step 4 simple gauge rendering is implemented. Digital, bar, level, +and status gauges render Dear PyGui drawlist visuals; bar and level gauges draw threshold bands and +markers. Gauge creation functions still register pure gauge state without requiring a Dear PyGui +context, and when a Dear PyGui context is active they create a group plus drawlist shell. Runtime value updates are intended for the Dear PyGui GUI thread. Attached gauges redraw immediately for simple value/configuration changes. + +Analog, segmented, compass, thermometer, battery, and multi-value visuals are scheduled for later +steps and may still use placeholder rendering. diff --git a/codex/AGENTS.md b/codex/AGENTS.md index 0262e93..9d024f8 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -2,7 +2,7 @@ ## Current status -Step 3 completed and verified with automated checks. Ready to start Step 4. +Step 4 completed and verified with automated checks. Step 4 commit is pending. ## Completed steps @@ -30,6 +30,15 @@ Step 1 - Public API contract and pure core: Step 4 - Digital, bar, level, and status gauges. +Implemented during Step 4: + +- Added digital gauge drawlist rendering with formatted value text. +- Added bar and level gauge rendering with horizontal/vertical fill geometry. +- Added threshold band and marker drawing for bar/level gauges. +- Added status light rendering with configured status state colors and text. +- Added Dear PyGui item callback wiring for gauges with `callback=`. +- Added Step 4 examples and tests for geometry, runtime updates, thresholds, markers, and status states. + Step 3 - Widget shell and renderer foundation: Implemented during Step 3: @@ -69,7 +78,7 @@ Implemented during Step 2: ## Known issues -Manual visual example checks were not run in this headless session. +Manual visual example checks for Steps 3 and 4 were not run in this headless session. ## Commands used @@ -102,7 +111,14 @@ Manual visual example checks were not run in this headless session. - Ran `uv run ruff format .`. - Ran `uv run ruff format --check .`. - Ran `uv run pyright`. +- Committed Step 3 with `git commit -m "step 3: add widget shell and renderer foundation"` before starting Step 4. +- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 4 changes. +- Implemented Step 4 digital, bar, level, and status rendering, threshold/marker drawing, callback wiring, examples, README status update, and tests. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. ## Next action -Implement Step 4. +Commit Step 4, then implement Step 5. diff --git a/examples/basic_bar.py b/examples/basic_bar.py new file mode 100644 index 0000000..e7efc58 --- /dev/null +++ b/examples/basic_bar.py @@ -0,0 +1,33 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + with dpg.window(label="Bar Gauge", width=360, height=180): + dpgg.bar_gauge( + tag="boost", + label="Boost", + value=18.5, + min_value=0, + max_value=30, + unit="psi", + precision=1, + width=300, + height=100, + ) + + dpg.create_viewport(title="dpg-gauges bar", width=400, height=220) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/basic_level.py b/examples/basic_level.py new file mode 100644 index 0000000..abe0a30 --- /dev/null +++ b/examples/basic_level.py @@ -0,0 +1,30 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + with dpg.window(label="Level Gauge", width=260, height=320): + dpgg.level_gauge( + tag="fuel", + label="Fuel", + value=68, + unit="%", + width=180, + height=240, + ) + + dpg.create_viewport(title="dpg-gauges level", width=300, height=360) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/basic_status.py b/examples/basic_status.py new file mode 100644 index 0000000..2209c1b --- /dev/null +++ b/examples/basic_status.py @@ -0,0 +1,35 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + states = { + "ok": dpgg.StatusState((0, 220, 120, 255), "OK"), + "warn": dpgg.StatusState((255, 190, 0, 255), "WARN"), + "fault": dpgg.StatusState((255, 45, 45, 255), "FAULT"), + } + with dpg.window(label="Status Light", width=300, height=160): + dpgg.status_light( + tag="ecu", + label="ECU", + state="ok", + states=states, + width=240, + height=80, + ) + + dpg.create_viewport(title="dpg-gauges status", width=340, height=200) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/thresholds_markers.py b/examples/thresholds_markers.py new file mode 100644 index 0000000..a5cfcf8 --- /dev/null +++ b/examples/thresholds_markers.py @@ -0,0 +1,37 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + thresholds = [ + dpgg.ThresholdBand(0, 30, (60, 180, 90, 150), "normal"), + dpgg.ThresholdBand(70, 100, (255, 80, 45, 150), "hot"), + ] + markers = [dpgg.GaugeMarker(85, (255, 255, 255, 255), "limit", 3)] + with dpg.window(label="Thresholds and Markers", width=420, height=220): + dpgg.bar_gauge( + tag="coolant", + label="Coolant", + value=76, + unit="C", + width=340, + height=100, + thresholds=thresholds, + markers=markers, + ) + + dpg.create_viewport(title="dpg-gauges thresholds", width=460, height=260) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/src/dpg_gauges/renderer.py b/src/dpg_gauges/renderer.py index c267245..79873e9 100644 --- a/src/dpg_gauges/renderer.py +++ b/src/dpg_gauges/renderer.py @@ -1,20 +1,23 @@ -"""Dear PyGui renderer foundation for gauge placeholders.""" +"""Dear PyGui renderer foundation for gauge drawlists.""" from __future__ import annotations import time -from typing import Any +from typing import Any, cast import dearpygui.dearpygui as dpg from .animation import advance_animation from .draw_layers import DrawLayers -from .scales import format_gauge_value +from .gauges import BarGaugeConfig, LevelGaugeConfig, StatusLightConfig +from .scales import fill_fraction, fill_span, format_gauge_value, normalized_span from .sizing import effective_size, update_measured_size from .smoothing import advance_smoothing from .state import DirtyFlags, GaugeState, animation_config, size_state_for_gauge, update_state_size from .themes import GaugeStyle -from .types import SmoothingConfig +from .types import SmoothingConfig, StatusState, Tag + +Rect = tuple[float, float, float, float] class GaugeRenderer: @@ -29,7 +32,7 @@ class GaugeRenderer: state = self.state self._measure() self._advance_values() - self._redraw_placeholder() + self._redraw() state.dirty = DirtyFlags.NONE if state.animation_state.active or state.smoothing_state.active: self.schedule_frame() @@ -63,7 +66,6 @@ class GaugeRenderer: state = self.state if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag): return - width = dpg.get_item_width(state.drawlist_tag) or 0 height = dpg.get_item_height(state.drawlist_tag) or 0 size = size_state_for_gauge(state) @@ -83,7 +85,7 @@ class GaugeRenderer: state.smoothing_state, state.clamped_value, smoothing ) - def _redraw_placeholder(self) -> None: + def _redraw(self) -> None: state = self.state if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag): return @@ -97,12 +99,25 @@ class GaugeRenderer: self.layers.clear() style = state.config.style or GaugeStyle() + self._draw_frame(width, height, style) + if state.gauge_type == "digital": + self._draw_digital(width, height, style) + elif state.gauge_type in {"bar", "level"}: + self._draw_bar_or_level(width, height, style) + elif state.gauge_type == "status_light": + self._draw_status_light(width, height, style) + else: + self._draw_placeholder_text(width, height, style) + + def _draw_frame(self, width: int, height: int, style: GaugeStyle) -> None: + state = self.state + drawlist = _drawlist_tag(state) background = self.layers.background.add(f"{state.tag}__background") frame = self.layers.static.add(f"{state.tag}__frame") dpg.draw_rectangle( (0, 0), (width, height), - parent=state.drawlist_tag, + parent=drawlist, tag=background, color=style.border_color, fill=style.background_color, @@ -112,36 +127,200 @@ class GaugeRenderer: dpg.draw_rectangle( (4, 4), (max(width - 4, 4), max(height - 4, 4)), - parent=state.drawlist_tag, + parent=drawlist, tag=frame, color=style.frame_color, rounding=4, thickness=1, ) - label_y = 10 + def _draw_label(self, style: GaugeStyle, *, x: float = 12, y: float = 10) -> None: + state = self.state if state.config.show_label and state.config.label: + drawlist = _drawlist_tag(state) label = self.layers.text.add(f"{state.tag}__label") dpg.draw_text( - (12, label_y), + (x, y), state.config.label, - parent=state.drawlist_tag, + parent=drawlist, tag=label, color=style.muted_text_color, size=14, ) - value = self._display_text() + def _draw_digital(self, _width: int, height: int, style: GaugeStyle) -> None: + self._draw_label(style) + state = self.state + drawlist = _drawlist_tag(state) + value_tag = self.layers.dynamic.add(f"{state.tag}__value") + size = max(min(height * 0.34, 34), 20) + dpg.draw_text( + (12, max((height - size) * 0.52, 26)), + self._display_text(), + parent=drawlist, + tag=value_tag, + color=style.value_color if not state.invalid_value else style.redline_color, + size=size, + ) + + def _draw_placeholder_text(self, _width: int, height: int, style: GaugeStyle) -> None: + self._draw_label(style) + state = self.state + drawlist = _drawlist_tag(state) value_tag = self.layers.dynamic.add(f"{state.tag}__value") dpg.draw_text( (12, max(height * 0.45 - 10, 24)), - value, - parent=state.drawlist_tag, + self._display_text(), + parent=drawlist, tag=value_tag, color=style.value_color if not state.invalid_value else style.redline_color, size=24, ) + def _draw_bar_or_level(self, width: int, height: int, style: GaugeStyle) -> None: + state = self.state + config = state.config + if not isinstance(config, BarGaugeConfig | LevelGaugeConfig): + return + self._draw_label(style) + bar = _bar_rect(width, height, config.orientation) + self._draw_bar_track(bar, style, config.corner_radius) + self._draw_thresholds(bar, config.orientation, config.corner_radius) + if state.displayed_value is not None and not state.invalid_value: + start, end = fill_span( + state.displayed_value, + config.min_value, + config.max_value, + fill_mode=config.fill_mode, + clamp=config.clamp, + ) + self._draw_bar_fill(_oriented_rect(bar, start, end, config.orientation), style) + self._draw_markers(bar, config.orientation) + if config.show_value: + drawlist = _drawlist_tag(state) + value_tag = self.layers.text.add(f"{state.tag}__value") + dpg.draw_text( + (12, height - 24), + self._display_text(), + parent=drawlist, + tag=value_tag, + color=style.text_color, + size=14, + ) + + def _draw_bar_track(self, rect: Rect, style: GaugeStyle, rounding: float) -> None: + drawlist = _drawlist_tag(self.state) + tag = self.layers.static.add(f"{self.state.tag}__bar_track") + dpg.draw_rectangle( + (rect[0], rect[1]), + (rect[2], rect[3]), + parent=drawlist, + tag=tag, + color=style.border_color, + fill=style.frame_color, + rounding=rounding, + thickness=1, + ) + + def _draw_bar_fill(self, rect: Rect, style: GaugeStyle) -> None: + if rect[2] <= rect[0] or rect[3] <= rect[1]: + return + drawlist = _drawlist_tag(self.state) + tag = self.layers.dynamic.add(f"{self.state.tag}__bar_fill") + dpg.draw_rectangle( + (rect[0], rect[1]), + (rect[2], rect[3]), + parent=drawlist, + tag=tag, + color=style.value_color, + fill=style.value_color, + rounding=2, + thickness=1, + ) + + def _draw_thresholds(self, rect: Rect, orientation: str, rounding: float) -> None: + state = self.state + drawlist = _drawlist_tag(state) + for index, threshold in enumerate(state.config.thresholds): + start, end = normalized_span( + threshold.start, threshold.end, state.config.min_value, state.config.max_value + ) + threshold_rect = _oriented_rect(rect, start, end, orientation) + if threshold_rect[2] <= threshold_rect[0] or threshold_rect[3] <= threshold_rect[1]: + continue + tag = self.layers.thresholds.add(f"{state.tag}__threshold_{index}") + dpg.draw_rectangle( + (threshold_rect[0], threshold_rect[1]), + (threshold_rect[2], threshold_rect[3]), + parent=drawlist, + tag=tag, + color=threshold.color, + fill=threshold.color, + rounding=rounding, + thickness=1, + ) + + def _draw_markers(self, rect: Rect, orientation: str) -> None: + state = self.state + drawlist = _drawlist_tag(state) + for index, marker in enumerate(state.config.markers): + fraction = fill_fraction( + marker.value, state.config.min_value, state.config.max_value, clamp=True + ) + tag = self.layers.markers.add(f"{state.tag}__marker_{index}") + if orientation == "vertical": + y = rect[3] - (rect[3] - rect[1]) * fraction + dpg.draw_line( + (rect[0] - 4, y), + (rect[2] + 4, y), + parent=drawlist, + tag=tag, + color=marker.color, + thickness=marker.thickness, + ) + else: + x = rect[0] + (rect[2] - rect[0]) * fraction + dpg.draw_line( + (x, rect[1] - 4), + (x, rect[3] + 4), + parent=drawlist, + tag=tag, + color=marker.color, + thickness=marker.thickness, + ) + + def _draw_status_light(self, width: int, height: int, style: GaugeStyle) -> None: + state = self.state + config = state.config + if not isinstance(config, StatusLightConfig): + return + drawlist = _drawlist_tag(state) + status = _resolve_status(config, style) + radius = max(min(width, height) * 0.18, 10) + center = (max(radius + 14, 28), height * 0.5) + light = self.layers.dynamic.add(f"{state.tag}__status_light") + dpg.draw_circle( + center, + radius, + parent=drawlist, + tag=light, + color=style.border_color, + fill=status.color, + thickness=2, + ) + text = status.text or str(config.state) + if config.show_label and config.label: + text = f"{config.label}: {text}" + text_tag = self.layers.text.add(f"{state.tag}__status_text") + dpg.draw_text( + (center[0] + radius + 14, max(center[1] - 9, 8)), + text, + parent=drawlist, + tag=text_tag, + color=status.value_color or style.text_color, + size=18, + ) + def _display_text(self) -> str: state = self.state if not state.config.show_value: @@ -164,3 +343,40 @@ def _smoothing_config(state: GaugeState) -> SmoothingConfig | None: if isinstance(smoothing, bool): return SmoothingConfig(enabled=smoothing) return smoothing + + +def _drawlist_tag(state: GaugeState) -> Tag: + return cast(Tag, state.drawlist_tag) + + +def _bar_rect(width: int, height: int, orientation: str) -> Rect: + if orientation == "vertical": + top = 34.0 + bottom = max(height - 34.0, top + 1.0) + bar_width = min(max(width * 0.34, 28.0), 56.0) + left = (width - bar_width) / 2.0 + return left, top, left + bar_width, bottom + top = 36.0 + bottom = max(height - 32.0, top + 1.0) + return 12.0, top, max(width - 12.0, 13.0), bottom + + +def _oriented_rect(rect: Rect, start: float, end: float, orientation: str) -> Rect: + start = min(max(start, 0.0), 1.0) + end = min(max(end, 0.0), 1.0) + if orientation == "vertical": + height = rect[3] - rect[1] + return rect[0], rect[3] - height * end, rect[2], rect[3] - height * start + width = rect[2] - rect[0] + return rect[0] + width * start, rect[1], rect[0] + width * end, rect[3] + + +def _resolve_status(config: StatusLightConfig, style: GaugeStyle) -> StatusState: + if config.states is not None and config.state in config.states: + return config.states[config.state] + if isinstance(config.state, bool): + return StatusState( + style.value_color if config.state else style.frame_color, + "ON" if config.state else "OFF", + ) + return StatusState(style.value_color, str(config.state)) diff --git a/src/dpg_gauges/scales.py b/src/dpg_gauges/scales.py index 7e49040..d7820dd 100644 --- a/src/dpg_gauges/scales.py +++ b/src/dpg_gauges/scales.py @@ -4,6 +4,7 @@ from __future__ import annotations import math from collections.abc import Sequence +from typing import Literal from .exceptions import GaugeConfigError, GaugeValueError from .types import GaugeMarker, ThresholdBand @@ -59,6 +60,29 @@ def fill_fraction(value: float, min_value: float, max_value: float, *, clamp: bo return normalize_value(value, min_value, max_value, clamp=clamp) +def fill_span( + value: float, + min_value: float, + max_value: float, + *, + fill_mode: Literal["clamp", "centered"] = "clamp", + clamp: bool = True, +) -> tuple[float, float]: + fraction = fill_fraction(value, min_value, max_value, clamp=clamp) + if fill_mode == "clamp": + return 0.0, fraction + zero = fill_fraction(0.0, min_value, max_value, clamp=True) + return min(zero, fraction), max(zero, fraction) + + +def normalized_span( + start: float, end: float, min_value: float, max_value: float +) -> tuple[float, float]: + first = fill_fraction(start, min_value, max_value, clamp=True) + second = fill_fraction(end, min_value, max_value, clamp=True) + return min(first, second), max(first, second) + + def generate_ticks(min_value: float, max_value: float, count: int) -> list[float]: validate_range(min_value, max_value) if count < 2: diff --git a/src/dpg_gauges/state.py b/src/dpg_gauges/state.py index f9be7e4..fa900f0 100644 --- a/src/dpg_gauges/state.py +++ b/src/dpg_gauges/state.py @@ -244,6 +244,8 @@ def update_runtime_gauge(tag: Tag, **config: Any) -> None: dirty |= DirtyFlags.STATIC | DirtyFlags.DYNAMIC | DirtyFlags.TEXT if any(name in config for name in ("thresholds", "markers")): dirty |= DirtyFlags.STATIC | DirtyFlags.DYNAMIC + if any(name in config for name in ("state", "states")): + dirty |= DirtyFlags.DYNAMIC | DirtyFlags.TEXT if any( name in config for name in ("label", "unit", "precision", "show_value", "show_unit") ): diff --git a/src/dpg_gauges/widget.py b/src/dpg_gauges/widget.py index 7043ae0..789d41f 100644 --- a/src/dpg_gauges/widget.py +++ b/src/dpg_gauges/widget.py @@ -132,6 +132,8 @@ def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[Confi ) renderer = GaugeRenderer(state) state.renderer = renderer + if config.callback is not None: + dpg.configure_item(drawlist, callback=_item_callback, user_data=state.tag) renderer.render() return GaugeContext(state) @@ -225,3 +227,11 @@ def _add_tooltip(parent: Tag, tooltip: str | None) -> None: return tooltip_tag = dpg.add_tooltip(parent) dpg.add_text(tooltip, parent=tooltip_tag) + + +def _item_callback(_sender: Tag, _app_data: object, user_data: Tag) -> None: + from .state import get_gauge_state + + state = get_gauge_state(user_data) + if state.config.callback is not None: + state.config.callback(state.tag, state.target_value, state.config.user_data) diff --git a/tests/test_step4_simple_gauges.py b/tests/test_step4_simple_gauges.py new file mode 100644 index 0000000..8993c02 --- /dev/null +++ b/tests/test_step4_simple_gauges.py @@ -0,0 +1,88 @@ +# pyright: reportGeneralTypeIssues=false + +from collections.abc import Generator + +import dearpygui.dearpygui as dpg +import pytest + +import dpg_gauges as dpgg +from dpg_gauges.gauges import LevelGaugeConfig, StatusLightConfig +from dpg_gauges.scales import fill_fraction, fill_span, normalized_span +from dpg_gauges.state import clear_registry, get_gauge_state + + +@pytest.fixture +def dpg_context() -> Generator[None]: + clear_registry() + dpgg.configure() + dpg.create_context() + try: + yield + finally: + clear_registry() + dpg.destroy_context() + + +def test_bar_geometry_helpers_clamp_and_center() -> None: + assert fill_fraction(150, 0, 100, clamp=True) == 1.0 + assert fill_span(25, 0, 100) == (0.0, 0.25) + assert fill_span(-25, -100, 100, fill_mode="centered") == (0.375, 0.5) + assert fill_span(25, -100, 100, fill_mode="centered") == (0.5, 0.625) + assert normalized_span(90, 10, 0, 100) == (0.1, 0.9) + + +def test_bar_runtime_renders_fill_thresholds_and_markers(dpg_context: None) -> None: + threshold = dpgg.ThresholdBand(35, 50, (255, 120, 0, 120)) + marker = dpgg.GaugeMarker(50, (255, 255, 255, 255)) + with dpg.window(label="Bar"): + dpgg.bar_gauge( + tag="boost", + label="Boost", + value=25, + min_value=0, + max_value=50, + width=240, + height=90, + thresholds=[threshold], + markers=[marker], + ) + + debug = dpgg.get_gauge_debug_state("boost") + assert "boost__bar_fill" in debug["renderer"]["layers"]["dynamic"] + assert "boost__threshold_0" in debug["renderer"]["layers"]["thresholds"] + assert "boost__marker_0" in debug["renderer"]["layers"]["markers"] + + dpgg.set_value("boost", 100) + state = get_gauge_state("boost") + assert state.clamped_value == 50 + assert state.displayed_value == 50 + + +def test_level_runtime_uses_vertical_fill(dpg_context: None) -> None: + with dpg.window(label="Level"): + dpgg.level_gauge(tag="fuel", label="Fuel", value=75, width=120, height=180) + + debug = dpgg.get_gauge_debug_state("fuel") + config = get_gauge_state("fuel").config + assert isinstance(config, LevelGaugeConfig) + assert config.orientation == "vertical" + assert "fuel__bar_fill" in debug["renderer"]["layers"]["dynamic"] + + +def test_status_light_uses_configured_state_text_and_color(dpg_context: None) -> None: + states = { + "ok": dpgg.StatusState((0, 220, 120, 255), "OK"), + "fault": dpgg.StatusState((255, 45, 45, 255), "FAULT"), + } + with dpg.window(label="Status"): + dpgg.status_light(tag="ecu", label="ECU", state="ok", states=states, width=180, height=70) + + debug = dpgg.get_gauge_debug_state("ecu") + assert "ecu__status_light" in debug["renderer"]["layers"]["dynamic"] + assert "ecu__status_text" in debug["renderer"]["layers"]["text"] + + dpgg.configure_gauge("ecu", state="fault") + config = get_gauge_state("ecu").config + assert isinstance(config, StatusLightConfig) + assert config.state == "fault" + assert get_gauge_state("ecu").dirty == 0 From 1a476bc91d2919641ce0fa8362dba34410d90d32 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Thu, 2 Jul 2026 14:47:05 +0200 Subject: [PATCH 4/8] step 5: complete first gauge catalogue --- README.md | 11 +- codex/AGENTS.md | 29 +++- examples/basic_battery.py | 25 +++ examples/basic_compass.py | 23 +++ examples/basic_multi_value.py | 29 ++++ examples/basic_segmented.py | 32 ++++ examples/basic_thermometer.py | 32 ++++ src/dpg_gauges/renderer.py | 315 +++++++++++++++++++++++++++++++++- src/dpg_gauges/scales.py | 17 ++ src/dpg_gauges/state.py | 6 +- tests/test_step5_catalogue.py | 91 ++++++++++ 11 files changed, 594 insertions(+), 16 deletions(-) create mode 100644 examples/basic_battery.py create mode 100644 examples/basic_compass.py create mode 100644 examples/basic_multi_value.py create mode 100644 examples/basic_segmented.py create mode 100644 examples/basic_thermometer.py create mode 100644 tests/test_step5_catalogue.py diff --git a/README.md b/README.md index ace68fd..39c41d1 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,10 @@ Dear PyGui gauge widgets for dashboards and telemetry displays. -Current implementation status: Step 4 simple gauge rendering is implemented. Digital, bar, level, -and status gauges render Dear PyGui drawlist visuals; bar and level gauges draw threshold bands and -markers. Gauge creation functions still register pure gauge state without requiring a Dear PyGui -context, and when a Dear PyGui context is active they create a group plus drawlist shell. +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, +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. - -Analog, segmented, compass, thermometer, battery, and multi-value visuals are scheduled for later -steps and may still use placeholder rendering. diff --git a/codex/AGENTS.md b/codex/AGENTS.md index 9d024f8..aca5430 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -2,7 +2,7 @@ ## Current status -Step 4 completed and verified with automated checks. Step 4 commit is pending. +Step 5 completed and verified with automated checks. Step 5 commit is pending. ## Completed steps @@ -28,7 +28,20 @@ Step 1 - Public API contract and pure core: ## Current step -Step 4 - Digital, bar, level, and status gauges. +Step 6 - High-rate updates, animation, and stress examples. + +Step 5 - Analog, segmented, compass, thermometer, battery, and multi-value gauges: + +Implemented during Step 5: + +- Added analog and compass dial rendering with arcs, ticks, tick labels, needles, redline bands, and markers. +- Added compass heading wraparound for runtime values. +- Added segmented gauge rendering with active segment calculation. +- Added thermometer and battery-specific drawlist rendering. +- Added multi-value gauge rendering. +- Added Step 5 examples and tests for angle math, tick generation, compass wraparound, segment activation, battery clamp, and multi-value rendering. + +Step 4 - Digital, bar, level, and status gauges: Implemented during Step 4: @@ -78,7 +91,7 @@ Implemented during Step 2: ## Known issues -Manual visual example checks for Steps 3 and 4 were not run in this headless session. +Manual visual example checks for Steps 3, 4, and 5 were not run in this headless session. ## Commands used @@ -97,6 +110,14 @@ Manual visual example checks for Steps 3 and 4 were not run in this headless ses - Ran `uv run ruff check .`. - Ran `uv run ruff format --check .`. - Ran `uv run pyright`. +- Committed Step 4 with `git commit -m "step 4: add digital bar level and status gauges"` before starting Step 5. +- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 5 changes. +- Implemented Step 5 first catalogue rendering, compass wraparound, examples, README status update, 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`. @@ -121,4 +142,4 @@ Manual visual example checks for Steps 3 and 4 were not run in this headless ses ## Next action -Commit Step 4, then implement Step 5. +Commit Step 5, then implement Step 6. diff --git a/examples/basic_battery.py b/examples/basic_battery.py new file mode 100644 index 0000000..7115ca8 --- /dev/null +++ b/examples/basic_battery.py @@ -0,0 +1,25 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + with dpg.window(label="Battery Gauge", width=320, height=180): + dpgg.battery_gauge( + tag="battery", label="Battery", value=82, unit="%", width=260, height=110 + ) + + dpg.create_viewport(title="dpg-gauges battery", width=360, height=220) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/basic_compass.py b/examples/basic_compass.py new file mode 100644 index 0000000..b88acb1 --- /dev/null +++ b/examples/basic_compass.py @@ -0,0 +1,23 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + with dpg.window(label="Compass Gauge", width=320, height=320): + dpgg.compass_gauge(tag="heading", label="Heading", value=275, width=240, height=240) + + dpg.create_viewport(title="dpg-gauges compass", width=360, height=360) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/basic_multi_value.py b/examples/basic_multi_value.py new file mode 100644 index 0000000..311c886 --- /dev/null +++ b/examples/basic_multi_value.py @@ -0,0 +1,29 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + with dpg.window(label="Multi Value Gauge", width=340, height=220): + dpgg.multi_value_gauge( + tag="engine", + label="Engine", + values={"AFR": 12.8, "Oil": "58 psi", "Fuel": "43 psi"}, + width=280, + height=150, + ) + + dpg.create_viewport(title="dpg-gauges multi-value", width=380, height=260) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/basic_segmented.py b/examples/basic_segmented.py new file mode 100644 index 0000000..4919c61 --- /dev/null +++ b/examples/basic_segmented.py @@ -0,0 +1,32 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + with dpg.window(label="Segmented Gauge", width=360, height=160): + dpgg.segmented_gauge( + tag="shift", + label="Shift lights", + value=7200, + min_value=0, + max_value=8000, + segments=12, + width=300, + height=90, + ) + + dpg.create_viewport(title="dpg-gauges segmented", width=400, height=200) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/basic_thermometer.py b/examples/basic_thermometer.py new file mode 100644 index 0000000..78cfc42 --- /dev/null +++ b/examples/basic_thermometer.py @@ -0,0 +1,32 @@ +# pyright: reportGeneralTypeIssues=false + +import dearpygui.dearpygui as dpg + +import dpg_gauges as dpgg + + +def main() -> None: + dpg.create_context() + try: + with dpg.window(label="Thermometer Gauge", width=280, height=340): + dpgg.thermometer_gauge( + tag="coolant", + label="Coolant", + value=92, + min_value=40, + max_value=120, + unit="C", + width=180, + height=260, + ) + + dpg.create_viewport(title="dpg-gauges thermometer", width=320, height=380) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/src/dpg_gauges/renderer.py b/src/dpg_gauges/renderer.py index 79873e9..1b55107 100644 --- a/src/dpg_gauges/renderer.py +++ b/src/dpg_gauges/renderer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math import time from typing import Any, cast @@ -9,9 +10,26 @@ import dearpygui.dearpygui as dpg from .animation import advance_animation from .draw_layers import DrawLayers -from .gauges import BarGaugeConfig, LevelGaugeConfig, StatusLightConfig -from .scales import fill_fraction, fill_span, format_gauge_value, normalized_span -from .sizing import effective_size, update_measured_size +from .gauges import ( + AnalogGaugeConfig, + BarGaugeConfig, + BatteryGaugeConfig, + CompassGaugeConfig, + LevelGaugeConfig, + MultiValueGaugeConfig, + SegmentedGaugeConfig, + StatusLightConfig, +) +from .scales import ( + active_segments, + fill_fraction, + fill_span, + format_gauge_value, + generate_ticks, + normalized_span, + value_to_angle, +) +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 .themes import GaugeStyle @@ -102,8 +120,18 @@ class GaugeRenderer: self._draw_frame(width, height, style) if state.gauge_type == "digital": self._draw_digital(width, height, style) + elif state.gauge_type in {"analog", "compass"}: + self._draw_analog(width, height, style) elif state.gauge_type in {"bar", "level"}: self._draw_bar_or_level(width, height, style) + elif state.gauge_type == "segmented": + self._draw_segmented(width, height, style) + elif state.gauge_type == "thermometer": + self._draw_thermometer(width, height, style) + elif state.gauge_type == "battery": + self._draw_battery(width, height, style) + elif state.gauge_type == "multi_value": + self._draw_multi_value(width, height, style) elif state.gauge_type == "status_light": self._draw_status_light(width, height, style) else: @@ -321,6 +349,280 @@ class GaugeRenderer: size=18, ) + def _draw_analog(self, width: int, height: int, style: GaugeStyle) -> None: + state = self.state + config = state.config + if not isinstance(config, AnalogGaugeConfig | CompassGaugeConfig): + return + drawlist = _drawlist_tag(state) + rect = square_gauge_rect(width - 16, height - 16) + cx = 8 + rect.x + rect.width / 2.0 + cy = 8 + rect.y + rect.height / 2.0 + radius = max(min(rect.width, rect.height) * 0.43, 12.0) + if config.show_label and config.label: + self._draw_label(style, x=12, y=10) + self._draw_arc( + (cx, cy), + radius, + config.start_angle, + config.end_angle, + style.frame_color, + config.arc_width, + ) + for threshold in config.thresholds: + start = value_to_angle( + threshold.start, + config.min_value, + config.max_value, + config.start_angle, + config.end_angle, + ) + end = value_to_angle( + threshold.end, + config.min_value, + config.max_value, + config.start_angle, + config.end_angle, + ) + self._draw_arc((cx, cy), radius, start, end, threshold.color, config.arc_width + 2) + if config.redline_start is not None: + start = value_to_angle( + config.redline_start, + config.min_value, + config.max_value, + config.start_angle, + config.end_angle, + ) + self._draw_arc( + (cx, cy), radius, start, config.end_angle, style.redline_color, config.arc_width + 3 + ) + ticks = generate_ticks(config.min_value, config.max_value, max(config.major_ticks, 2)) + for index, tick in enumerate(ticks): + angle = value_to_angle( + tick, config.min_value, config.max_value, config.start_angle, config.end_angle + ) + outer = _point_on_circle(cx, cy, radius + 4, angle) + inner = _point_on_circle(cx, cy, radius - 10, angle) + tag = self.layers.static.add(f"{state.tag}__tick_{index}") + dpg.draw_line( + outer, inner, parent=drawlist, tag=tag, color=style.tick_color, thickness=2 + ) + if config.show_tick_labels: + label_pos = _point_on_circle(cx, cy, radius - 26, angle) + label = self.layers.text.add(f"{state.tag}__tick_label_{index}") + text = f"{tick:.0f}" + dpg.draw_text( + (label_pos[0] - 10, label_pos[1] - 6), + text, + parent=drawlist, + tag=label, + color=style.muted_text_color, + size=10, + ) + for index, marker in enumerate(config.markers): + angle = value_to_angle( + marker.value, + config.min_value, + config.max_value, + config.start_angle, + config.end_angle, + ) + outer = _point_on_circle(cx, cy, radius + 8, angle) + inner = _point_on_circle(cx, cy, radius - 18, angle) + tag = self.layers.markers.add(f"{state.tag}__marker_{index}") + dpg.draw_line( + outer, + inner, + parent=drawlist, + tag=tag, + color=marker.color, + thickness=marker.thickness, + ) + if state.displayed_value is not None and not state.invalid_value: + angle = value_to_angle( + state.displayed_value, + config.min_value, + config.max_value, + config.start_angle, + config.end_angle, + ) + end = _point_on_circle(cx, cy, radius * config.needle_length, angle) + needle = self.layers.dynamic.add(f"{state.tag}__needle") + dpg.draw_line( + (cx, cy), + end, + parent=drawlist, + tag=needle, + color=style.needle_color, + thickness=config.needle_width, + ) + cap = self.layers.dynamic.add(f"{state.tag}__center_cap") + dpg.draw_circle( + (cx, cy), + config.center_cap_radius, + parent=drawlist, + tag=cap, + color=style.border_color, + fill=style.value_color, + ) + if config.show_value: + value = self.layers.text.add(f"{state.tag}__value") + dpg.draw_text( + (cx - radius * 0.45, cy + radius * 0.38), + self._display_text(), + parent=drawlist, + tag=value, + color=style.text_color, + size=14, + ) + + def _draw_arc( + self, + center: tuple[float, float], + radius: float, + start_angle: float, + end_angle: float, + color: tuple[int, ...], + thickness: float, + ) -> None: + drawlist = _drawlist_tag(self.state) + steps = max(int(abs(end_angle - start_angle) / 8), 2) + previous = _point_on_circle(center[0], center[1], radius, start_angle) + for index in range(1, steps + 1): + angle = start_angle + (end_angle - start_angle) * (index / steps) + current = _point_on_circle(center[0], center[1], radius, angle) + tag = self.layers.static.add( + f"{self.state.tag}__arc_{len(self.layers.static.item_tags)}" + ) + dpg.draw_line( + previous, current, parent=drawlist, tag=tag, color=color, thickness=thickness + ) + previous = current + + def _draw_segmented(self, width: int, height: int, style: GaugeStyle) -> None: + state = self.state + config = state.config + if not isinstance(config, SegmentedGaugeConfig): + return + self._draw_label(style) + drawlist = _drawlist_tag(state) + count = config.segments + active = 0 + if state.displayed_value is not None and not state.invalid_value: + active = active_segments( + state.displayed_value, config.min_value, config.max_value, count + ) + gap = 4.0 + left, top, right, bottom = 12.0, 38.0, max(width - 12.0, 13.0), max(height - 28.0, 39.0) + segment_width = max((right - left - gap * (count - 1)) / count, 1.0) + for index in range(count): + x1 = left + index * (segment_width + gap) + x2 = x1 + segment_width + color = style.value_color if index < active else style.frame_color + tag = self.layers.dynamic.add(f"{state.tag}__segment_{index}") + dpg.draw_rectangle( + (x1, top), + (x2, bottom), + parent=drawlist, + tag=tag, + color=style.border_color, + fill=color, + rounding=config.corner_radius, + ) + + def _draw_thermometer(self, width: int, height: int, style: GaugeStyle) -> None: + self._draw_bar_or_level(width, height, style) + drawlist = _drawlist_tag(self.state) + bulb = self.layers.dynamic.add(f"{self.state.tag}__bulb") + dpg.draw_circle( + (width / 2.0, max(height - 30.0, 18.0)), + 16, + parent=drawlist, + tag=bulb, + color=style.border_color, + fill=style.value_color, + ) + + def _draw_battery(self, width: int, height: int, style: GaugeStyle) -> None: + state = self.state + config = state.config + if not isinstance(config, BatteryGaugeConfig): + return + self._draw_label(style) + drawlist = _drawlist_tag(state) + body = (18.0, 40.0, max(width - 34.0, 58.0), max(height - 26.0, 74.0)) + nub = ( + body[2], + body[1] + (body[3] - body[1]) * 0.33, + body[2] + 10, + body[1] + (body[3] - body[1]) * 0.67, + ) + dpg.draw_rectangle( + (body[0], body[1]), + (body[2], body[3]), + parent=drawlist, + tag=self.layers.static.add(f"{state.tag}__battery_body"), + color=style.border_color, + thickness=2, + rounding=3, + ) + dpg.draw_rectangle( + (nub[0], nub[1]), + (nub[2], nub[3]), + parent=drawlist, + tag=self.layers.static.add(f"{state.tag}__battery_nub"), + color=style.border_color, + fill=style.border_color, + rounding=2, + ) + if state.displayed_value is not None and not state.invalid_value: + fraction = fill_fraction( + state.displayed_value, config.min_value, config.max_value, clamp=True + ) + pad = 5.0 + fill = ( + body[0] + pad, + body[1] + pad, + body[0] + pad + (body[2] - body[0] - pad * 2) * fraction, + body[3] - pad, + ) + if fill[2] > fill[0]: + dpg.draw_rectangle( + (fill[0], fill[1]), + (fill[2], fill[3]), + parent=drawlist, + tag=self.layers.dynamic.add(f"{state.tag}__battery_fill"), + color=style.value_color, + fill=style.value_color, + rounding=2, + ) + if config.show_value: + dpg.draw_text( + (18, height - 22), + self._display_text(), + parent=drawlist, + tag=self.layers.text.add(f"{state.tag}__value"), + color=style.text_color, + size=14, + ) + + def _draw_multi_value(self, _width: int, height: int, style: GaugeStyle) -> None: + state = self.state + config = state.config + if not isinstance(config, MultiValueGaugeConfig): + return + self._draw_label(style) + drawlist = _drawlist_tag(state) + values = config.values or {} + y = 34.0 + for index, (name, value) in enumerate(values.items()): + text = f"{name}: {value}" + tag = self.layers.text.add(f"{state.tag}__multi_{index}") + dpg.draw_text((12, y), text, parent=drawlist, tag=tag, color=style.text_color, size=16) + y += 22.0 + if y > height - 18: + break + def _display_text(self) -> str: state = self.state if not state.config.show_value: @@ -371,6 +673,13 @@ def _oriented_rect(rect: Rect, start: float, end: float, orientation: str) -> Re return rect[0] + width * start, rect[1], rect[0] + width * end, rect[3] +def _point_on_circle( + cx: float, cy: float, radius: float, angle_degrees: float +) -> tuple[float, float]: + radians = math.radians(angle_degrees) + return cx + math.cos(radians) * radius, cy - math.sin(radians) * radius + + def _resolve_status(config: StatusLightConfig, style: GaugeStyle) -> StatusState: if config.states is not None and config.state in config.states: return config.states[config.state] diff --git a/src/dpg_gauges/scales.py b/src/dpg_gauges/scales.py index d7820dd..3a641de 100644 --- a/src/dpg_gauges/scales.py +++ b/src/dpg_gauges/scales.py @@ -56,6 +56,13 @@ def value_to_angle( return start_angle + (end_angle - start_angle) * fraction +def compass_heading(value: float) -> float: + if not math.isfinite(value): + msg = "heading value must be finite" + raise GaugeValueError(msg) + return value % 360.0 + + def fill_fraction(value: float, min_value: float, max_value: float, *, clamp: bool = True) -> float: return normalize_value(value, min_value, max_value, clamp=clamp) @@ -83,6 +90,16 @@ def normalized_span( return min(first, second), max(first, second) +def active_segments( + value: float, min_value: float, max_value: float, segments: int, *, clamp: bool = True +) -> int: + if segments <= 0: + msg = "segments must be greater than zero" + raise GaugeConfigError(msg) + fraction = fill_fraction(value, min_value, max_value, clamp=clamp) + return min(max(math.ceil(fraction * segments), 0), segments) + + def generate_ticks(min_value: float, max_value: float, count: int) -> list[float]: validate_range(min_value, max_value) if count < 2: diff --git a/src/dpg_gauges/state.py b/src/dpg_gauges/state.py index fa900f0..3ddce76 100644 --- a/src/dpg_gauges/state.py +++ b/src/dpg_gauges/state.py @@ -9,8 +9,8 @@ from typing import Any from .animation import AnimationState, start_animation from .exceptions import GaugeConfigError, GaugeNotFoundError -from .gauges import GaugeConfig -from .scales import clamp_value, validate_markers, validate_thresholds +from .gauges import CompassGaugeConfig, GaugeConfig +from .scales import clamp_value, compass_heading, validate_markers, validate_thresholds from .sizing import RequestedSize, SizeState from .smoothing import SmoothingState from .themes import GaugeTheme, get_theme @@ -150,6 +150,8 @@ def clamp_runtime_value(value: GaugeValue, config: GaugeConfig) -> tuple[float | return None, value is not None if not math.isfinite(numeric): return None, True + if isinstance(config, CompassGaugeConfig): + numeric = compass_heading(numeric) if config.clamp: return clamp_value(numeric, config.min_value, config.max_value), False return numeric, False diff --git a/tests/test_step5_catalogue.py b/tests/test_step5_catalogue.py new file mode 100644 index 0000000..14ca624 --- /dev/null +++ b/tests/test_step5_catalogue.py @@ -0,0 +1,91 @@ +# pyright: reportGeneralTypeIssues=false + +from collections.abc import Generator + +import dearpygui.dearpygui as dpg +import pytest + +import dpg_gauges as dpgg +from dpg_gauges.scales import active_segments, compass_heading, generate_ticks, value_to_angle +from dpg_gauges.state import clear_registry, get_gauge_state + + +@pytest.fixture +def dpg_context() -> Generator[None]: + clear_registry() + dpgg.configure() + dpg.create_context() + try: + yield + finally: + clear_registry() + dpg.destroy_context() + + +def test_value_to_angle_ticks_and_compass_wrap() -> None: + assert value_to_angle(50, 0, 100, 225, -45) == 90 + assert generate_ticks(0, 100, 3) == [0, 50, 100] + assert compass_heading(370) == 10 + assert compass_heading(-10) == 350 + + +def test_analog_renders_needle_redline_and_marker(dpg_context: None) -> None: + marker = dpgg.GaugeMarker(6000, (255, 255, 255, 255)) + with dpg.window(label="Analog"): + dpgg.analog_gauge( + tag="rpm", + label="RPM", + value=3500, + max_value=8000, + redline_start=6500, + markers=[marker], + width=240, + height=200, + ) + + debug = dpgg.get_gauge_debug_state("rpm") + assert "rpm__needle" in debug["renderer"]["layers"]["dynamic"] + assert "rpm__marker_0" in debug["renderer"]["layers"]["markers"] + assert get_gauge_state("rpm").measured_width == 240 + + +def test_compass_wraps_runtime_heading(dpg_context: None) -> None: + with dpg.window(label="Compass"): + dpgg.compass_gauge(tag="heading", value=370, width=200, height=200) + + state = get_gauge_state("heading") + assert state.clamped_value == 10 + assert ( + "heading__needle" in dpgg.get_gauge_debug_state("heading")["renderer"]["layers"]["dynamic"] + ) + + +def test_segmented_activation_and_rendering(dpg_context: None) -> None: + assert active_segments(55, 0, 100, 10) == 6 + with dpg.window(label="Segments"): + dpgg.segmented_gauge(tag="shift", value=55, segments=10, width=260, height=80) + + dynamic = dpgg.get_gauge_debug_state("shift")["renderer"]["layers"]["dynamic"] + assert "shift__segment_0" in dynamic + assert "shift__segment_9" in dynamic + + +def test_battery_clamps_and_multi_value_renders(dpg_context: None) -> None: + with dpg.window(label="Catalogue"): + dpgg.battery_gauge(tag="battery", value=125, width=220, height=100) + dpgg.multi_value_gauge( + tag="engine", + label="Engine", + values={"AFR": 12.8, "Oil": "58 psi"}, + width=220, + height=110, + ) + + assert get_gauge_state("battery").clamped_value == 100 + assert ( + "battery__battery_fill" + in dpgg.get_gauge_debug_state("battery")["renderer"]["layers"]["dynamic"] + ) + text = dpgg.get_gauge_debug_state("engine")["renderer"]["layers"]["text"] + assert "engine__multi_0" in text + assert "engine__multi_1" in text From f4716e0d1977cebb0a4cd6a5af584c70b1c66e64 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Thu, 2 Jul 2026 15:06:10 +0200 Subject: [PATCH 5/8] step 6: add animation and high rate update stress tests --- README.md | 9 +-- codex/AGENTS.md | 28 ++++++-- examples/animations.py | 51 ++++++++++++++ examples/live_high_rate.py | 73 +++++++++++++++++++ examples/live_worst_case.py | 58 +++++++++++++++ src/dpg_gauges/diagnostics.py | 11 +++ src/dpg_gauges/renderer.py | 20 +++--- src/dpg_gauges/state.py | 22 +++++- tests/test_step6_animation_stress.py | 101 +++++++++++++++++++++++++++ 9 files changed, 352 insertions(+), 21 deletions(-) create mode 100644 examples/animations.py create mode 100644 examples/live_high_rate.py create mode 100644 examples/live_worst_case.py create mode 100644 tests/test_step6_animation_stress.py 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 From ee5b60e62000649ae1f2897f1fd22f5558658e29 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Thu, 2 Jul 2026 15:27:55 +0200 Subject: [PATCH 6/8] step 7: harden docs and prepare beta --- README.md | 85 +++++++++++++++++++++++++++++--- codex/AGENTS.md | 26 ++++++++-- docs/.gitkeep | 0 docs/API_REFERENCE.md | 41 +++++++++++++++ docs/EXAMPLES.md | 23 +++++++++ docs/GETTING_STARTED.md | 19 +++++++ pyproject.toml | 4 +- src/dpg_gauges/__init__.py | 4 ++ src/dpg_gauges/api.py | 9 ++++ src/dpg_gauges/diagnostics.py | 1 + src/dpg_gauges/widget.py | 12 +++++ tests/test_step7_hardening.py | 93 +++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 13 files changed, 306 insertions(+), 13 deletions(-) delete mode 100644 docs/.gitkeep create mode 100644 docs/API_REFERENCE.md create mode 100644 docs/EXAMPLES.md create mode 100644 docs/GETTING_STARTED.md create mode 100644 tests/test_step7_hardening.py diff --git a/README.md b/README.md index 008b6f3..2815472 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,82 @@ Dear PyGui gauge widgets for dashboards and telemetry displays. -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. +## Install -Runtime value updates are intended for the Dear PyGui GUI thread. Attached gauges redraw immediately -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. +```bash +uv add dpg-gauges +``` + +For local development from another project: + +```bash +uv add --editable ../dpg-gauges +``` + +## Minimal Example + +```python +import dearpygui.dearpygui as dpg +import dpg_gauges as dpgg + +dpg.create_context() +with dpg.window(label="Dashboard"): + dpgg.analog_gauge(tag="rpm", label="RPM", value=3200, max_value=8000, width=220, height=220) + dpgg.digital_gauge(tag="speed", label="Speed", value=88, unit="km/h", width=220, height=90) + +dpg.create_viewport(title="gauges", width=520, height=340) +dpg.setup_dearpygui() +dpg.show_viewport() +dpg.start_dearpygui() +dpg.destroy_context() +``` + +## Dashboard Example + +```python +with dpg.window(label="Engine"): + with dpgg.gauge_panel(label="Powertrain", width=-1, height=260): + with dpgg.gauge_grid(columns=3): + dpgg.analog_gauge(tag="rpm", label="RPM", max_value=8000, redline_start=6500) + dpgg.bar_gauge(tag="boost", label="Boost", max_value=30, unit="psi") + dpgg.status_light(tag="ecu", label="ECU", state=True) +``` + +## GUI-Thread Contract + +Dear PyGui item operations must happen on the GUI thread. Gauge creation and runtime updates such as +`set_value`, `update_gauge`, `set_thresholds`, and `delete_gauge` are GUI-thread functions. Worker +threads may produce data, but applications should marshal the latest values to a Dear PyGui frame +callback before calling dpg-gauges APIs. + +## Animation And Smoothing + +Animation changes the displayed value over a short duration without changing the raw target value. +Smoothing improves readability during high-rate updates. + +```python +dpgg.digital_gauge( + tag="speed", + animation=dpgg.AnimationConfig(enabled=True, duration=0.15), + smoothing=dpgg.SmoothingConfig(enabled=True, alpha=0.25, max_step=5), +) +``` + +Disable animation with `animation=False` for immediate response. + +## Thresholds And Markers + +Thresholds and markers are visual only; they do not trigger callbacks or alarms. + +```python +dpgg.bar_gauge( + tag="coolant", + min_value=40, + max_value=120, + thresholds=[dpgg.ThresholdBand(100, 120, (255, 45, 45, 150))], + markers=[dpgg.GaugeMarker(110, (255, 255, 255, 255), thickness=3)], +) +``` + +Implemented gauge types: analog, digital, bar, level, status light, segmented, compass, +thermometer, battery, and multi-value. diff --git a/codex/AGENTS.md b/codex/AGENTS.md index 304708e..07f3eea 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -2,7 +2,7 @@ ## Current status -Step 6 completed and verified with automated checks. Step 6 commit is pending. +Step 7 completed and verified with automated checks. Step 7 commit and beta tag are pending. ## Completed steps @@ -30,6 +30,16 @@ Step 1 - Public API contract and pure core: Step 7 - Documentation, hardening, and beta release. +Implemented during Step 7: + +- Rewrote `README.md` with install, minimal examples, dashboard usage, GUI-thread expectations, animation/smoothing, and thresholds/markers. +- Added `docs/GETTING_STARTED.md`, `docs/EXAMPLES.md`, and `docs/API_REFERENCE.md`. +- Added docstrings for all public exported callables. +- Reviewed public API exports and added `__version__`. +- Added Step 7 hardening tests for unknown gauges, duplicate tags, invalid ranges, invalid thresholds, invalid markers, non-finite values, deleted updates, hidden sizing, and animation disabled/enabled behavior. +- Bumped package version to `0.1.0b1`. +- Verified a local editable install from a temporary project. + Step 6 - High-rate updates, animation, and stress examples: Implemented during Step 6: @@ -103,7 +113,7 @@ Implemented during Step 2: ## Known issues -Manual visual/stress example checks for Steps 3, 4, 5, and 6 were not run in this headless session. +Manual visual/stress example checks for Steps 3, 4, 5, 6, and 7 were not run in this headless session. ## Commands used @@ -130,6 +140,16 @@ Manual visual/stress example checks for Steps 3, 4, 5, and 6 were not run in thi - Ran `uv run ruff format .`. - Ran `uv run ruff format --check .`. - Ran `uv run pyright`. +- Committed Step 6 with `git commit -m "step 6: add animation and high rate update stress tests"` before starting Step 7. +- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 7 changes. +- Implemented Step 7 README/docs, public callable docstrings, hardening tests, version bump, and README/API status updates. +- Ran `uv run python - <<'PY' ...` to verify all exported callables have docstrings. +- Ran `uv sync`. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. +- Created `/tmp/opencode/dpg-gauges-editable-check`, ran `uv init --bare --name dpg-gauges-editable-check`, `uv add --editable /home/hector/projects/dpg-gauges`, and `uv run python -c "import dpg_gauges as dpgg; print(dpgg.__name__)"`. - 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. @@ -162,4 +182,4 @@ Manual visual/stress example checks for Steps 3, 4, 5, and 6 were not run in thi ## Next action -Commit Step 6, then implement Step 7. +Commit Step 7 and tag `v0.1.0b1` if requested. diff --git a/docs/.gitkeep b/docs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..b4181a6 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,41 @@ +# API Reference + +## Configuration + +- `configure(**config)`: set package-wide defaults for later gauges. + +## Gauge Creation + +Creation functions are context managers and accept common options such as `tag`, `label`, `value`, +`min_value`, `max_value`, `unit`, `width`, `height`, `show`, `tooltip`, `callback`, `theme`, +`style`, `animation`, `smoothing`, `thresholds`, and `markers`. + +- `analog_gauge(**config)` +- `digital_gauge(**config)` +- `bar_gauge(**config)` +- `level_gauge(**config)` +- `status_light(**config)` +- `segmented_gauge(**config)` +- `compass_gauge(**config)` +- `thermometer_gauge(**config)` +- `battery_gauge(**config)` +- `multi_value_gauge(**config)` + +## Layout Helpers + +- `gauge_panel(**config)`: child-window panel for grouped gauges. +- `gauge_grid(**config)`: lightweight horizontal gauge group. + +## Runtime API + +- `set_value(tag, value)`: set the latest target value. +- `get_value(tag)`: return the latest raw target value. +- `update_gauge(tag, **config)`: update value and/or configuration. +- `configure_gauge(tag, **config)`: update configuration. +- `set_thresholds(tag, thresholds)`: replace visual threshold bands. +- `set_markers(tag, markers)`: replace visual markers. +- `set_show(tag, show)`: show or hide a gauge. +- `delete_gauge(tag)`: remove a gauge. +- `get_gauge_debug_state(tag)`: return internal debug state. + +Runtime functions are GUI-thread functions unless explicitly documented otherwise. diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md new file mode 100644 index 0000000..d959d5b --- /dev/null +++ b/docs/EXAMPLES.md @@ -0,0 +1,23 @@ +# Examples + +Run examples with `uv run python examples/.py`. + +- `basic_analog.py` +- `basic_digital.py` +- `basic_bar.py` +- `basic_level.py` +- `basic_status.py` +- `basic_segmented.py` +- `basic_compass.py` +- `basic_thermometer.py` +- `basic_battery.py` +- `basic_multi_value.py` +- `thresholds_markers.py` +- `animations.py` +- `dashboard.py` +- `sizing.py` +- `live_high_rate.py` +- `live_worst_case.py` + +The live examples use a producer thread for data generation and a Dear PyGui frame callback for all +gauge updates. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..353a610 --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,19 @@ +# Getting Started + +Install with `uv add dpg-gauges`, then import as `import dpg_gauges as dpgg`. + +Create gauges inside a Dear PyGui context: + +```python +import dearpygui.dearpygui as dpg +import dpg_gauges as dpgg + +dpg.create_context() +with dpg.window(label="Dashboard"): + dpgg.digital_gauge(tag="speed", label="Speed", value=72, unit="km/h") + +dpgg.set_value("speed", 88) +``` + +All widget creation and runtime updates are Dear PyGui GUI-thread operations. If telemetry is +produced in a worker thread, store the latest value and apply it from a GUI frame callback. diff --git a/pyproject.toml b/pyproject.toml index 6253b55..403bf97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "dpg-gauges" -version = "0.1.0" -description = "Add your description here" +version = "0.1.0b1" +description = "Dear PyGui gauge widgets for dashboards and telemetry displays" readme = "README.md" authors = [ { name = "Hector van der Aa", email = "hector@h3cx.dev" } diff --git a/src/dpg_gauges/__init__.py b/src/dpg_gauges/__init__.py index 667dea2..16443d0 100644 --- a/src/dpg_gauges/__init__.py +++ b/src/dpg_gauges/__init__.py @@ -35,6 +35,8 @@ from .types import ( ThresholdBand, ) +__version__ = "0.1.0b1" + __all__ = [ "configure", "GaugeTheme", @@ -67,8 +69,10 @@ __all__ = [ "set_show", "delete_gauge", "get_gauge_debug_state", + "__version__", ] def main() -> None: + """Print a short message for the package console script.""" print("dpg-gauges is a library package; import dpg_gauges to use it.") diff --git a/src/dpg_gauges/api.py b/src/dpg_gauges/api.py index ac513be..81c864c 100644 --- a/src/dpg_gauges/api.py +++ b/src/dpg_gauges/api.py @@ -20,6 +20,7 @@ from .types import GaugeMarker, GaugeValue, Tag, ThresholdBand def configure(**config: Any) -> None: + """Configure package-wide defaults for subsequently created gauges.""" configure_gauges(**config) @@ -38,34 +39,42 @@ gauge_grid = _widget.gauge_grid def set_value(tag: Tag, value: GaugeValue) -> None: + """Set a gauge target value from the Dear PyGui GUI thread.""" set_runtime_value(tag, value) def get_value(tag: Tag) -> GaugeValue: + """Return the latest raw target value assigned to a gauge.""" return get_runtime_value(tag) def update_gauge(tag: Tag, **config: Any) -> None: + """Update a gauge value and/or configuration fields.""" update_runtime_gauge(tag, **config) def configure_gauge(tag: Tag, **config: Any) -> None: + """Update configuration fields for an existing gauge.""" update_runtime_gauge(tag, **config) def set_thresholds(tag: Tag, thresholds: list[ThresholdBand] | tuple[ThresholdBand, ...]) -> None: + """Replace the visual threshold bands for an existing gauge.""" set_runtime_thresholds(tag, thresholds) def set_markers(tag: Tag, markers: list[GaugeMarker] | tuple[GaugeMarker, ...]) -> None: + """Replace the visual markers for an existing gauge.""" set_runtime_markers(tag, markers) def set_show(tag: Tag, show: bool) -> None: + """Show or hide an existing gauge.""" set_runtime_show(tag, show) def delete_gauge(tag: Tag) -> None: + """Delete an existing gauge and its Dear PyGui shell when attached.""" delete_runtime_gauge(tag) diff --git a/src/dpg_gauges/diagnostics.py b/src/dpg_gauges/diagnostics.py index d3a7d15..fc8db6d 100644 --- a/src/dpg_gauges/diagnostics.py +++ b/src/dpg_gauges/diagnostics.py @@ -9,6 +9,7 @@ from .types import Tag def get_gauge_debug_state(tag: Tag) -> dict[str, Any]: + """Return renderer and state diagnostics for an existing gauge.""" state = get_gauge_state(tag) debug = { "tag": state.tag, diff --git a/src/dpg_gauges/widget.py b/src/dpg_gauges/widget.py index 789d41f..24e54f3 100644 --- a/src/dpg_gauges/widget.py +++ b/src/dpg_gauges/widget.py @@ -139,6 +139,7 @@ def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[Confi def gauge_panel(**config: Any) -> LayoutContext: + """Create a lightweight child-window container for related gauges.""" if not _dpg_context_active: return LayoutContext(None) tag = config.pop("tag", 0) @@ -160,6 +161,7 @@ def gauge_panel(**config: Any) -> LayoutContext: def gauge_grid(**config: Any) -> LayoutContext: + """Create a lightweight horizontal gauge layout container.""" if not _dpg_context_active: return LayoutContext(None) columns = max(int(config.pop("columns", 1)), 1) @@ -175,42 +177,52 @@ def gauge_grid(**config: Any) -> LayoutContext: def analog_gauge(**config: Any) -> GaugeContext[AnalogGaugeConfig]: + """Create an analog dial gauge with ticks, needle, thresholds, and markers.""" return create_gauge_context("analog", AnalogGaugeConfig(**config)) def digital_gauge(**config: Any) -> GaugeContext[DigitalGaugeConfig]: + """Create a large numeric readout gauge.""" return create_gauge_context("digital", DigitalGaugeConfig(**config)) def bar_gauge(**config: Any) -> GaugeContext[BarGaugeConfig]: + """Create a horizontal or vertical fill bar gauge.""" return create_gauge_context("bar", BarGaugeConfig(**config)) def level_gauge(**config: Any) -> GaugeContext[LevelGaugeConfig]: + """Create a tank or level-style vertical gauge.""" return create_gauge_context("level", LevelGaugeConfig(**config)) def status_light(**config: Any) -> GaugeContext[StatusLightConfig]: + """Create an LED-style status indicator gauge.""" return create_gauge_context("status_light", StatusLightConfig(**config)) def segmented_gauge(**config: Any) -> GaugeContext[SegmentedGaugeConfig]: + """Create a segmented strip gauge for shift-light or cell-style displays.""" return create_gauge_context("segmented", SegmentedGaugeConfig(**config)) def compass_gauge(**config: Any) -> GaugeContext[CompassGaugeConfig]: + """Create a compass gauge that wraps numeric headings to 0-360 degrees.""" return create_gauge_context("compass", CompassGaugeConfig(**config)) def thermometer_gauge(**config: Any) -> GaugeContext[ThermometerGaugeConfig]: + """Create a thermometer-style temperature gauge.""" return create_gauge_context("thermometer", ThermometerGaugeConfig(**config)) def battery_gauge(**config: Any) -> GaugeContext[BatteryGaugeConfig]: + """Create a battery-style percentage or value gauge.""" return create_gauge_context("battery", BatteryGaugeConfig(**config)) def multi_value_gauge(**config: Any) -> GaugeContext[MultiValueGaugeConfig]: + """Create a compact grouped key/value readout gauge.""" return create_gauge_context("multi_value", MultiValueGaugeConfig(**config)) diff --git a/tests/test_step7_hardening.py b/tests/test_step7_hardening.py new file mode 100644 index 0000000..deadddc --- /dev/null +++ b/tests/test_step7_hardening.py @@ -0,0 +1,93 @@ +# pyright: reportGeneralTypeIssues=false + +from collections.abc import Generator + +import dearpygui.dearpygui as dpg +import pytest + +import dpg_gauges as dpgg +from dpg_gauges.exceptions import GaugeConfigError, GaugeNotFoundError +from dpg_gauges.gauges import DigitalGaugeConfig +from dpg_gauges.state import clear_registry, get_gauge_state, register_gauge +from dpg_gauges.types import AnimationConfig + + +@pytest.fixture(autouse=True) +def clean_registry() -> Generator[None]: + clear_registry() + dpgg.configure() + yield + clear_registry() + + +@pytest.fixture +def dpg_context() -> Generator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +def test_public_exports_and_version() -> None: + assert dpgg.__version__ == "0.1.0b1" + for name in dpgg.__all__: + assert hasattr(dpgg, name) + + +def test_unknown_duplicate_and_deleted_gauge_errors() -> None: + with pytest.raises(GaugeNotFoundError): + dpgg.set_value("missing", 1) + + dpgg.digital_gauge(tag="speed") + with pytest.raises(GaugeConfigError): + register_gauge("digital", DigitalGaugeConfig(tag="speed")) + + dpgg.delete_gauge("speed") + with pytest.raises(GaugeNotFoundError): + dpgg.update_gauge("speed", value=2) + + +def test_invalid_range_threshold_marker_and_non_finite_value() -> None: + with pytest.raises(GaugeConfigError): + dpgg.digital_gauge(tag="bad", min_value=10, max_value=0) + + dpgg.bar_gauge(tag="bar", min_value=0, max_value=100) + with pytest.raises(GaugeConfigError): + dpgg.set_thresholds("bar", [dpgg.ThresholdBand(float("nan"), 10, (255, 0, 0))]) + with pytest.raises(GaugeConfigError): + dpgg.set_markers("bar", [dpgg.GaugeMarker(float("nan"), (255, 255, 255))]) + + dpgg.set_value("bar", float("inf")) + state = get_gauge_state("bar") + assert state.invalid_value is True + assert state.clamped_value is None + + +def test_hidden_layout_sizing_survives_show_later(dpg_context: None) -> None: + with dpg.window(label="Hidden"): + dpgg.digital_gauge(tag="hidden", value=1, width=180, height=80, show=False) + + state = get_gauge_state("hidden") + assert state.last_nonzero_width == 180 + assert state.last_nonzero_height == 80 + + dpgg.set_show("hidden", True) + assert state.config.show is True + assert state.last_nonzero_width == 180 + + +def test_animation_disabled_and_enabled_modes() -> None: + dpgg.digital_gauge(tag="instant", value=0, animation=False) + dpgg.set_value("instant", 100) + instant = get_gauge_state("instant") + assert instant.animation_state.active is False + assert instant.displayed_value == 100 + + dpgg.digital_gauge( + tag="animated", value=0, animation=AnimationConfig(enabled=True, duration=1.0) + ) + dpgg.set_value("animated", 100) + animated = get_gauge_state("animated") + assert animated.animation_state.active is True + assert animated.animation_state.target_value == 100 diff --git a/uv.lock b/uv.lock index 5ce68d0..701161d 100644 --- a/uv.lock +++ b/uv.lock @@ -36,7 +36,7 @@ wheels = [ [[package]] name = "dpg-gauges" -version = "0.1.0" +version = "0.1.0b1" source = { editable = "." } dependencies = [ { name = "dearpygui" }, From 451557299e1074275d07df489d8df8aaf7687c31 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Thu, 2 Jul 2026 15:47:12 +0200 Subject: [PATCH 7/8] improve gauge function signatures --- src/dpg_gauges/widget.py | 463 +++++++++++++++++++++++++++++++++++---- 1 file changed, 423 insertions(+), 40 deletions(-) diff --git a/src/dpg_gauges/widget.py b/src/dpg_gauges/widget.py index 24e54f3..2fd9b20 100644 --- a/src/dpg_gauges/widget.py +++ b/src/dpg_gauges/widget.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from dataclasses import dataclass from types import TracebackType -from typing import Any, Generic, TypeVar, cast +from typing import Any, Generic, Literal, TypeVar, cast import dearpygui.dearpygui as dpg @@ -23,7 +24,18 @@ from .gauges import ( ) from .renderer import GaugeRenderer from .state import GaugeState, register_gauge -from .types import Tag +from .themes import GaugeStyle, GaugeTheme +from .types import ( + AnimationConfig, + Callback, + GaugeMarker, + GaugeValue, + SmoothingConfig, + StatusState, + Tag, + ThresholdBand, + ValueFormatter, +) ConfigT = TypeVar("ConfigT", bound=GaugeConfig) @@ -138,92 +150,463 @@ def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[Confi return GaugeContext(state) -def gauge_panel(**config: Any) -> LayoutContext: +def gauge_panel( + *, + tag: Tag | None = None, + label: str | None = None, + width: int = 0, + height: int = 0, + show: bool = True, + border: bool = True, + autosize_x: bool = False, + autosize_y: bool = False, + tooltip: str | None = None, + **config: Any, +) -> LayoutContext: """Create a lightweight child-window container for related gauges.""" if not _dpg_context_active: return LayoutContext(None) - tag = config.pop("tag", 0) - label = config.pop("label", None) - tooltip = config.pop("tooltip", None) container = dpg.add_child_window( - tag=tag, - label=label, - width=config.pop("width", 0), - height=config.pop("height", 0), - show=config.pop("show", True), - border=config.pop("border", True), - autosize_x=config.pop("autosize_x", False), - autosize_y=config.pop("autosize_y", False), + tag=tag or 0, + label=label or "", + width=width, + height=height, + show=show, + border=border, + autosize_x=autosize_x, + autosize_y=autosize_y, **config, ) _add_tooltip(container, tooltip) return LayoutContext(container) -def gauge_grid(**config: Any) -> LayoutContext: +def gauge_grid( + *, + columns: int = 1, + tag: Tag | None = None, + width: int = 0, + height: int = 0, + show: bool = True, + **config: Any, +) -> LayoutContext: """Create a lightweight horizontal gauge layout container.""" if not _dpg_context_active: return LayoutContext(None) - columns = max(int(config.pop("columns", 1)), 1) + columns = max(int(columns), 1) container = dpg.add_group( - tag=config.pop("tag", 0), - width=config.pop("width", 0), - height=config.pop("height", 0), - show=config.pop("show", True), + tag=tag or 0, + width=width, + height=height, + show=show, horizontal=columns > 1, **config, ) return LayoutContext(container) -def analog_gauge(**config: Any) -> GaugeContext[AnalogGaugeConfig]: +def analog_gauge( + *, + tag: Tag | None = None, + label: str | None = None, + value: GaugeValue = None, + min_value: float = 0.0, + max_value: float = 100.0, + unit: str = "", + precision: int = 0, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + show: bool = True, + show_label: bool = True, + show_value: bool = True, + show_unit: bool = True, + tooltip: str | None = None, + callback: Callback | None = None, + user_data: Any = None, + theme: str | GaugeTheme | None = None, + style: GaugeStyle | None = None, + animation: AnimationConfig | bool | None = None, + smoothing: SmoothingConfig | bool | None = None, + clamp: bool = True, + format_value: ValueFormatter | None = None, + thresholds: Sequence[ThresholdBand] = (), + markers: Sequence[GaugeMarker] = (), + start_angle: float = 225.0, + end_angle: float = -45.0, + major_ticks: int = 8, + minor_ticks: int = 4, + show_tick_labels: bool = True, + needle_length: float = 0.82, + needle_width: float = 3.0, + center_cap_radius: float = 7.0, + arc_width: float = 8.0, + maintain_aspect: bool = True, + redline_start: float | None = None, + **config: Any, +) -> GaugeContext[AnalogGaugeConfig]: """Create an analog dial gauge with ticks, needle, thresholds, and markers.""" - return create_gauge_context("analog", AnalogGaugeConfig(**config)) + return create_gauge_context("analog", AnalogGaugeConfig(**_config_from_locals(locals()))) -def digital_gauge(**config: Any) -> GaugeContext[DigitalGaugeConfig]: +def digital_gauge( + *, + tag: Tag | None = None, + label: str | None = None, + value: GaugeValue = None, + min_value: float = 0.0, + max_value: float = 100.0, + unit: str = "", + precision: int = 0, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + show: bool = True, + show_label: bool = True, + show_value: bool = True, + show_unit: bool = True, + tooltip: str | None = None, + callback: Callback | None = None, + user_data: Any = None, + theme: str | GaugeTheme | None = None, + style: GaugeStyle | None = None, + animation: AnimationConfig | bool | None = None, + smoothing: SmoothingConfig | bool | None = None, + clamp: bool = True, + format_value: ValueFormatter | None = None, + thresholds: Sequence[ThresholdBand] = (), + markers: Sequence[GaugeMarker] = (), + **config: Any, +) -> GaugeContext[DigitalGaugeConfig]: """Create a large numeric readout gauge.""" - return create_gauge_context("digital", DigitalGaugeConfig(**config)) + return create_gauge_context("digital", DigitalGaugeConfig(**_config_from_locals(locals()))) -def bar_gauge(**config: Any) -> GaugeContext[BarGaugeConfig]: +def bar_gauge( + *, + tag: Tag | None = None, + label: str | None = None, + value: GaugeValue = None, + min_value: float = 0.0, + max_value: float = 100.0, + unit: str = "", + precision: int = 0, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + show: bool = True, + show_label: bool = True, + show_value: bool = True, + show_unit: bool = True, + tooltip: str | None = None, + callback: Callback | None = None, + user_data: Any = None, + theme: str | GaugeTheme | None = None, + style: GaugeStyle | None = None, + animation: AnimationConfig | bool | None = None, + smoothing: SmoothingConfig | bool | None = None, + clamp: bool = True, + format_value: ValueFormatter | None = None, + thresholds: Sequence[ThresholdBand] = (), + markers: Sequence[GaugeMarker] = (), + orientation: Literal["horizontal", "vertical"] = "horizontal", + fill_mode: Literal["clamp", "centered"] = "clamp", + corner_radius: float = 4.0, + show_scale: bool = True, + stretch: bool = True, + **config: Any, +) -> GaugeContext[BarGaugeConfig]: """Create a horizontal or vertical fill bar gauge.""" - return create_gauge_context("bar", BarGaugeConfig(**config)) + return create_gauge_context("bar", BarGaugeConfig(**_config_from_locals(locals()))) -def level_gauge(**config: Any) -> GaugeContext[LevelGaugeConfig]: +def level_gauge( + *, + tag: Tag | None = None, + label: str | None = None, + value: GaugeValue = None, + min_value: float = 0.0, + max_value: float = 100.0, + unit: str = "", + precision: int = 0, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + show: bool = True, + show_label: bool = True, + show_value: bool = True, + show_unit: bool = True, + tooltip: str | None = None, + callback: Callback | None = None, + user_data: Any = None, + theme: str | GaugeTheme | None = None, + style: GaugeStyle | None = None, + animation: AnimationConfig | bool | None = None, + smoothing: SmoothingConfig | bool | None = None, + clamp: bool = True, + format_value: ValueFormatter | None = None, + thresholds: Sequence[ThresholdBand] = (), + markers: Sequence[GaugeMarker] = (), + orientation: Literal["horizontal", "vertical"] = "vertical", + fill_mode: Literal["clamp", "centered"] = "clamp", + corner_radius: float = 4.0, + show_scale: bool = True, + stretch: bool = True, + **config: Any, +) -> GaugeContext[LevelGaugeConfig]: """Create a tank or level-style vertical gauge.""" - return create_gauge_context("level", LevelGaugeConfig(**config)) + return create_gauge_context("level", LevelGaugeConfig(**_config_from_locals(locals()))) -def status_light(**config: Any) -> GaugeContext[StatusLightConfig]: +def status_light( + *, + tag: Tag | None = None, + label: str | None = None, + value: GaugeValue = None, + min_value: float = 0.0, + max_value: float = 100.0, + unit: str = "", + precision: int = 0, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + show: bool = True, + show_label: bool = True, + show_value: bool = True, + show_unit: bool = True, + tooltip: str | None = None, + callback: Callback | None = None, + user_data: Any = None, + theme: str | GaugeTheme | None = None, + style: GaugeStyle | None = None, + animation: AnimationConfig | bool | None = None, + smoothing: SmoothingConfig | bool | None = None, + clamp: bool = True, + format_value: ValueFormatter | None = None, + thresholds: Sequence[ThresholdBand] = (), + markers: Sequence[GaugeMarker] = (), + state: str | bool | int = False, + states: Mapping[Any, StatusState] | None = None, + blink: bool = False, + pulse: bool = False, + **config: Any, +) -> GaugeContext[StatusLightConfig]: """Create an LED-style status indicator gauge.""" - return create_gauge_context("status_light", StatusLightConfig(**config)) + return create_gauge_context("status_light", StatusLightConfig(**_config_from_locals(locals()))) -def segmented_gauge(**config: Any) -> GaugeContext[SegmentedGaugeConfig]: +def segmented_gauge( + *, + tag: Tag | None = None, + label: str | None = None, + value: GaugeValue = None, + min_value: float = 0.0, + max_value: float = 100.0, + unit: str = "", + precision: int = 0, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + show: bool = True, + show_label: bool = True, + show_value: bool = True, + show_unit: bool = True, + tooltip: str | None = None, + callback: Callback | None = None, + user_data: Any = None, + theme: str | GaugeTheme | None = None, + style: GaugeStyle | None = None, + animation: AnimationConfig | bool | None = None, + smoothing: SmoothingConfig | bool | None = None, + clamp: bool = True, + format_value: ValueFormatter | None = None, + thresholds: Sequence[ThresholdBand] = (), + markers: Sequence[GaugeMarker] = (), + orientation: Literal["horizontal", "vertical"] = "horizontal", + fill_mode: Literal["clamp", "centered"] = "clamp", + corner_radius: float = 4.0, + show_scale: bool = True, + stretch: bool = True, + segments: int = 10, + **config: Any, +) -> GaugeContext[SegmentedGaugeConfig]: """Create a segmented strip gauge for shift-light or cell-style displays.""" - return create_gauge_context("segmented", SegmentedGaugeConfig(**config)) + return create_gauge_context("segmented", SegmentedGaugeConfig(**_config_from_locals(locals()))) -def compass_gauge(**config: Any) -> GaugeContext[CompassGaugeConfig]: +def compass_gauge( + *, + tag: Tag | None = None, + label: str | None = None, + value: GaugeValue = None, + min_value: float = 0.0, + max_value: float = 360.0, + unit: str = "", + precision: int = 0, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + show: bool = True, + show_label: bool = True, + show_value: bool = True, + show_unit: bool = True, + tooltip: str | None = None, + callback: Callback | None = None, + user_data: Any = None, + theme: str | GaugeTheme | None = None, + style: GaugeStyle | None = None, + animation: AnimationConfig | bool | None = None, + smoothing: SmoothingConfig | bool | None = None, + clamp: bool = True, + format_value: ValueFormatter | None = None, + thresholds: Sequence[ThresholdBand] = (), + markers: Sequence[GaugeMarker] = (), + start_angle: float = 90.0, + end_angle: float = -270.0, + major_ticks: int = 8, + minor_ticks: int = 4, + show_tick_labels: bool = True, + needle_length: float = 0.82, + needle_width: float = 3.0, + center_cap_radius: float = 7.0, + arc_width: float = 8.0, + maintain_aspect: bool = True, + redline_start: float | None = None, + **config: Any, +) -> GaugeContext[CompassGaugeConfig]: """Create a compass gauge that wraps numeric headings to 0-360 degrees.""" - return create_gauge_context("compass", CompassGaugeConfig(**config)) + return create_gauge_context("compass", CompassGaugeConfig(**_config_from_locals(locals()))) -def thermometer_gauge(**config: Any) -> GaugeContext[ThermometerGaugeConfig]: +def thermometer_gauge( + *, + tag: Tag | None = None, + label: str | None = None, + value: GaugeValue = None, + min_value: float = 0.0, + max_value: float = 100.0, + unit: str = "", + precision: int = 0, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + show: bool = True, + show_label: bool = True, + show_value: bool = True, + show_unit: bool = True, + tooltip: str | None = None, + callback: Callback | None = None, + user_data: Any = None, + theme: str | GaugeTheme | None = None, + style: GaugeStyle | None = None, + animation: AnimationConfig | bool | None = None, + smoothing: SmoothingConfig | bool | None = None, + clamp: bool = True, + format_value: ValueFormatter | None = None, + thresholds: Sequence[ThresholdBand] = (), + markers: Sequence[GaugeMarker] = (), + orientation: Literal["horizontal", "vertical"] = "vertical", + fill_mode: Literal["clamp", "centered"] = "clamp", + corner_radius: float = 4.0, + show_scale: bool = True, + stretch: bool = True, + **config: Any, +) -> GaugeContext[ThermometerGaugeConfig]: """Create a thermometer-style temperature gauge.""" - return create_gauge_context("thermometer", ThermometerGaugeConfig(**config)) + return create_gauge_context( + "thermometer", ThermometerGaugeConfig(**_config_from_locals(locals())) + ) -def battery_gauge(**config: Any) -> GaugeContext[BatteryGaugeConfig]: +def battery_gauge( + *, + tag: Tag | None = None, + label: str | None = None, + value: GaugeValue = None, + min_value: float = 0.0, + max_value: float = 100.0, + unit: str = "", + precision: int = 0, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + show: bool = True, + show_label: bool = True, + show_value: bool = True, + show_unit: bool = True, + tooltip: str | None = None, + callback: Callback | None = None, + user_data: Any = None, + theme: str | GaugeTheme | None = None, + style: GaugeStyle | None = None, + animation: AnimationConfig | bool | None = None, + smoothing: SmoothingConfig | bool | None = None, + clamp: bool = True, + format_value: ValueFormatter | None = None, + thresholds: Sequence[ThresholdBand] = (), + markers: Sequence[GaugeMarker] = (), + orientation: Literal["horizontal", "vertical"] = "vertical", + fill_mode: Literal["clamp", "centered"] = "clamp", + corner_radius: float = 4.0, + show_scale: bool = True, + stretch: bool = True, + charging: bool | None = None, + **config: Any, +) -> GaugeContext[BatteryGaugeConfig]: """Create a battery-style percentage or value gauge.""" - return create_gauge_context("battery", BatteryGaugeConfig(**config)) + return create_gauge_context("battery", BatteryGaugeConfig(**_config_from_locals(locals()))) -def multi_value_gauge(**config: Any) -> GaugeContext[MultiValueGaugeConfig]: +def multi_value_gauge( + *, + tag: Tag | None = None, + label: str | None = None, + value: GaugeValue = None, + min_value: float = 0.0, + max_value: float = 100.0, + unit: str = "", + precision: int = 0, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + show: bool = True, + show_label: bool = True, + show_value: bool = True, + show_unit: bool = True, + tooltip: str | None = None, + callback: Callback | None = None, + user_data: Any = None, + theme: str | GaugeTheme | None = None, + style: GaugeStyle | None = None, + animation: AnimationConfig | bool | None = None, + smoothing: SmoothingConfig | bool | None = None, + clamp: bool = True, + format_value: ValueFormatter | None = None, + thresholds: Sequence[ThresholdBand] = (), + markers: Sequence[GaugeMarker] = (), + values: Mapping[str, GaugeValue] | None = None, + **config: Any, +) -> GaugeContext[MultiValueGaugeConfig]: """Create a compact grouped key/value readout gauge.""" - return create_gauge_context("multi_value", MultiValueGaugeConfig(**config)) + return create_gauge_context( + "multi_value", MultiValueGaugeConfig(**_config_from_locals(locals())) + ) + + +def _config_from_locals(values: dict[str, Any]) -> dict[str, Any]: + config = dict(values.pop("config")) + config.update(values) + return config def _default_width(gauge_type: str) -> int: From 5c40968fccad560506a4e673d7f0e1ec03a25d0c Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Tue, 7 Jul 2026 16:44:10 +0200 Subject: [PATCH 8/8] Release 1.0.0 --- pyproject.toml | 2 +- src/dpg_gauges/__init__.py | 2 +- tests/test_step7_hardening.py | 2 +- uv.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 403bf97..53e4736 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dpg-gauges" -version = "0.1.0b1" +version = "1.0.0" description = "Dear PyGui gauge widgets for dashboards and telemetry displays" readme = "README.md" authors = [ diff --git a/src/dpg_gauges/__init__.py b/src/dpg_gauges/__init__.py index 16443d0..ca078ca 100644 --- a/src/dpg_gauges/__init__.py +++ b/src/dpg_gauges/__init__.py @@ -35,7 +35,7 @@ from .types import ( ThresholdBand, ) -__version__ = "0.1.0b1" +__version__ = "1.0.0" __all__ = [ "configure", diff --git a/tests/test_step7_hardening.py b/tests/test_step7_hardening.py index deadddc..c5b13c3 100644 --- a/tests/test_step7_hardening.py +++ b/tests/test_step7_hardening.py @@ -30,7 +30,7 @@ def dpg_context() -> Generator[None]: def test_public_exports_and_version() -> None: - assert dpgg.__version__ == "0.1.0b1" + assert dpgg.__version__ == "1.0.0" for name in dpgg.__all__: assert hasattr(dpgg, name) diff --git a/uv.lock b/uv.lock index 701161d..6f97ba2 100644 --- a/uv.lock +++ b/uv.lock @@ -36,7 +36,7 @@ wheels = [ [[package]] name = "dpg-gauges" -version = "0.1.0b1" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "dearpygui" },