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..2815472 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# dpg-gauges + +Dear PyGui gauge widgets for dashboards and telemetry displays. + +## Install + +```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 668df4e..07f3eea 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -2,15 +2,99 @@ ## Current status -Project instruction set initialized. +Step 7 completed and verified with automated checks. Step 7 commit and beta tag are pending. ## 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 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: + +- 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: + +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: + +- 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: + +- 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: + +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,17 +109,77 @@ 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 +## Known issues -None yet. +Manual visual/stress example checks for Steps 3, 4, 5, 6, and 7 were not run in this headless session. ## Commands used - 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`. +- 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`. +- 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. +- 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`. +- 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. +- 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`. +- 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 +## Next action -Implement Step 1. +Commit Step 7 and tag `v0.1.0b1` if requested. 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/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/examples/.gitkeep b/examples/.gitkeep new file mode 100644 index 0000000..e69de29 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/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_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_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_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/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_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_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/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/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/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/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/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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..53e4736 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "dpg-gauges" +version = "1.0.0" +description = "Dear PyGui gauge widgets for dashboards and telemetry displays" +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..ca078ca --- /dev/null +++ b/src/dpg_gauges/__init__.py @@ -0,0 +1,78 @@ +"""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, +) + +__version__ = "1.0.0" + +__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", + "__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/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..81c864c --- /dev/null +++ b/src/dpg_gauges/api.py @@ -0,0 +1,81 @@ +"""Public API wrappers for logical gauge state.""" + +from __future__ import annotations + +from typing import Any + +from . import widget as _widget +from .gauges import GaugeConfig +from .state import ( + configure_gauges, + delete_runtime_gauge, + get_runtime_value, + 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 package-wide defaults for subsequently created gauges.""" + configure_gauges(**config) + + +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: + """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) + + +__all_configs__ = (GaugeConfig,) diff --git a/src/dpg_gauges/diagnostics.py b/src/dpg_gauges/diagnostics.py new file mode 100644 index 0000000..fc8db6d --- /dev/null +++ b/src/dpg_gauges/diagnostics.py @@ -0,0 +1,43 @@ +"""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]: + """Return renderer and state diagnostics for an existing gauge.""" + state = get_gauge_state(tag) + debug = { + "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, + "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() + return debug diff --git a/src/dpg_gauges/draw_layers.py b/src/dpg_gauges/draw_layers.py new file mode 100644 index 0000000..a1ee462 --- /dev/null +++ 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/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..135b8de --- /dev/null +++ b/src/dpg_gauges/renderer.py @@ -0,0 +1,691 @@ +"""Dear PyGui renderer foundation for gauge drawlists.""" + +from __future__ import annotations + +import math +import time +from typing import Any, cast + +import dearpygui.dearpygui as dpg + +from .animation import advance_animation +from .draw_layers import DrawLayers +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, + smoothing_config, + update_state_size, +) +from .themes import GaugeStyle +from .types import StatusState, Tag + +Rect = tuple[float, float, float, float] + + +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() + 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.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 + ) + + def _redraw(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() + 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: + 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=drawlist, + 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=drawlist, + tag=frame, + color=style.frame_color, + rounding=4, + thickness=1, + ) + + 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( + (x, y), + state.config.label, + parent=drawlist, + tag=label, + color=style.muted_text_color, + size=14, + ) + + 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)), + 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 _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: + 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 _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 _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] + 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 new file mode 100644 index 0000000..3a641de --- /dev/null +++ b/src/dpg_gauges/scales.py @@ -0,0 +1,145 @@ +"""Pure scale, formatting, and value mapping helpers.""" + +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 + + +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 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) + + +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 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: + 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..ddd7a62 --- /dev/null +++ b/src/dpg_gauges/state.py @@ -0,0 +1,334 @@ +"""Global configuration, gauge registry, and runtime state.""" + +from __future__ import annotations + +import math +import time +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 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 +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 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: + 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 + + +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 | 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 + 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: + 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) + + +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 ("state", "states")): + dirty |= DirtyFlags.DYNAMIC | DirtyFlags.TEXT + 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 + render_runtime_gauge(state) + + +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: + 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 + 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..2fd9b20 --- /dev/null +++ b/src/dpg_gauges/widget.py @@ -0,0 +1,632 @@ +"""Dear PyGui widget shell creation helpers.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from types import TracebackType +from typing import Any, Generic, Literal, 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 .themes import GaugeStyle, GaugeTheme +from .types import ( + AnimationConfig, + Callback, + GaugeMarker, + GaugeValue, + SmoothingConfig, + StatusState, + Tag, + ThresholdBand, + ValueFormatter, +) + +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 + if config.callback is not None: + dpg.configure_item(drawlist, callback=_item_callback, user_data=state.tag) + renderer.render() + return GaugeContext(state) + + +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) + container = dpg.add_child_window( + 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( + *, + 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(columns), 1) + container = dpg.add_group( + tag=tag or 0, + width=width, + height=height, + show=show, + horizontal=columns > 1, + **config, + ) + return LayoutContext(container) + + +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_from_locals(locals()))) + + +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_from_locals(locals()))) + + +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_from_locals(locals()))) + + +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_from_locals(locals()))) + + +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_from_locals(locals()))) + + +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_from_locals(locals()))) + + +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_from_locals(locals()))) + + +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_from_locals(locals())) + ) + + +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_from_locals(locals()))) + + +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_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: + 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) + + +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/.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/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 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 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 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 diff --git a/tests/test_step7_hardening.py b/tests/test_step7_hardening.py new file mode 100644 index 0000000..c5b13c3 --- /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__ == "1.0.0" + 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 new file mode 100644 index 0000000..6f97ba2 --- /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 = "1.0.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" }, +]