10 Commits

51 changed files with 4476 additions and 8 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.venv/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.11

94
README.md Normal file
View File

@@ -0,0 +1,94 @@
# 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)
```
Gauges also accept Dear PyGui's usual `parent` argument for adding a gauge to an
existing container after that container has already been created:
```python
dpg.add_window(tag="data_input", label="Data Input")
dpgg.analog_gauge(tag="rpm", parent="data_input", label="RPM")
```
The `parent` is applied to the gauge's outer group. The gauge's internal drawlist
is then parented to that group.
## 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.

View File

@@ -2,15 +2,99 @@
## Current status ## Current status
Project instruction set initialized. Step 7 completed and verified with automated checks. Step 7 commit and beta tag are pending.
## Completed steps ## 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 ## 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 ## Design decisions
@@ -25,17 +109,77 @@ Step 1 - Public API contract and pure core.
- Gauges are display-only for the first release. - Gauges are display-only for the first release.
- Thresholds and redline zones are visual only; they do not trigger events. - Thresholds and redline zones are visual only; they do not trigger events.
- The public API should feel like normal Dear PyGui context-manager usage. - 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 ## Commands used
- Read `codex-old/README.md`, `codex-old/FEATURES.md`, `codex-old/ARCHITECTURE.md`, `codex-old/STEPS.md`, and `codex-old/AGENTS.md`. - 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. - Inspected `/home/hector/projects/dpg-map` package, examples, tests, docs, and pyproject structure.
- Created the initial `codex/` instruction documents for `dpg-gauges`. - 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.

View File

@@ -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. 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. 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. 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. 6. Do not casually change public API once introduced.
7. Dear PyGui calls must happen on the GUI thread only. 7. Dear PyGui calls must happen on the GUI thread only.
8. Runtime gauge updates are GUI-thread-only unless explicitly documented otherwise. 8. Runtime gauge updates are GUI-thread-only unless explicitly documented otherwise.
9. Prefer stable, boring implementation over clever drawing tricks. 9. Prefer stable, boring implementation over clever drawing tricks.
10. Keep each step shippable and tested before moving to the next. 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 ## Initial setup instructions

41
docs/API_REFERENCE.md Normal file
View File

@@ -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.

23
docs/EXAMPLES.md Normal file
View File

@@ -0,0 +1,23 @@
# Examples
Run examples with `uv run python examples/<name>.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.

19
docs/GETTING_STARTED.md Normal file
View File

@@ -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.

0
examples/.gitkeep Normal file
View File

51
examples/animations.py Normal file
View File

@@ -0,0 +1,51 @@
# pyright: reportGeneralTypeIssues=false
import math
import time
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
def main() -> None:
dpg.create_context()
started = time.monotonic()
def update() -> None:
elapsed = time.monotonic() - started
dpgg.set_value("animated", 50 + math.sin(elapsed * 2.0) * 50)
dpgg.set_value("smoothed", 50 + math.sin(elapsed * 7.0) * 50)
dpg.set_frame_callback(dpg.get_frame_count() + 1, update)
try:
with dpg.window(label="Animations", width=460, height=260):
dpgg.digital_gauge(
tag="animated",
label="Animated",
value=0,
width=360,
height=90,
animation=dpgg.AnimationConfig(enabled=True, duration=0.35),
)
dpgg.bar_gauge(
tag="smoothed",
label="Smoothed",
value=0,
width=360,
height=90,
animation=False,
smoothing=dpgg.SmoothingConfig(enabled=True, alpha=0.18, max_step=8),
)
dpg.create_viewport(title="dpg-gauges animations", width=500, height=300)
dpg.setup_dearpygui()
dpg.set_frame_callback(1, update)
dpg.show_viewport()
dpg.start_dearpygui()
finally:
dpg.destroy_context()
if __name__ == "__main__":
main()

25
examples/basic_analog.py Normal file
View File

@@ -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()

33
examples/basic_bar.py Normal file
View File

@@ -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()

25
examples/basic_battery.py Normal file
View File

@@ -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()

23
examples/basic_compass.py Normal file
View File

@@ -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()

25
examples/basic_digital.py Normal file
View File

@@ -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()

30
examples/basic_level.py Normal file
View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

35
examples/basic_status.py Normal file
View File

@@ -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()

View File

@@ -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()

35
examples/dashboard.py Normal file
View File

@@ -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()

View File

@@ -0,0 +1,73 @@
# pyright: reportGeneralTypeIssues=false
import math
import threading
import time
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
def main() -> None:
latest = {"rpm": 0.0, "speed": 0.0}
lock = threading.Lock()
stop = threading.Event()
def producer() -> None:
started = time.monotonic()
while not stop.is_set():
elapsed = time.monotonic() - started
with lock:
latest["rpm"] = 4000 + math.sin(elapsed * 9.0) * 3500
latest["speed"] = 120 + math.sin(elapsed * 3.0) * 80
time.sleep(1 / 240)
def gui_update() -> None:
with lock:
rpm = latest["rpm"]
speed = latest["speed"]
dpgg.set_value("rpm", rpm)
dpgg.set_value("speed", speed)
dpg.set_frame_callback(dpg.get_frame_count() + 1, gui_update)
dpg.create_context()
thread = threading.Thread(target=producer, daemon=True)
try:
with dpg.window(label="High Rate", width=620, height=360):
dpgg.analog_gauge(
tag="rpm",
label="RPM",
value=0,
max_value=8000,
redline_start=6500,
width=240,
height=240,
animation=False,
smoothing=dpgg.SmoothingConfig(enabled=True, alpha=0.22, max_step=500),
)
dpgg.digital_gauge(
tag="speed",
label="Speed",
value=0,
max_value=240,
unit="km/h",
width=260,
height=90,
animation=dpgg.AnimationConfig(enabled=True, duration=0.12),
)
dpg.create_viewport(title="dpg-gauges high rate", width=660, height=400)
dpg.setup_dearpygui()
thread.start()
dpg.set_frame_callback(1, gui_update)
dpg.show_viewport()
dpg.start_dearpygui()
finally:
stop.set()
thread.join(timeout=1.0)
dpg.destroy_context()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,58 @@
# pyright: reportGeneralTypeIssues=false
import math
import random
import threading
import time
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
def main() -> None:
latest = {"rpm": 0.0, "boost": 0.0, "fuel": 0.0, "battery": 0.0}
lock = threading.Lock()
stop = threading.Event()
def producer() -> None:
started = time.monotonic()
while not stop.is_set():
elapsed = time.monotonic() - started
with lock:
latest["rpm"] = random.uniform(0, 8000)
latest["boost"] = 15 + math.sin(elapsed * 25.0) * 15
latest["fuel"] = 50 + math.sin(elapsed * 4.0) * 50
latest["battery"] = random.uniform(0, 100)
time.sleep(1 / 500)
def gui_update() -> None:
with lock:
snapshot = dict(latest)
for tag, value in snapshot.items():
dpgg.set_value(tag, value)
dpg.set_frame_callback(dpg.get_frame_count() + 1, gui_update)
dpg.create_context()
thread = threading.Thread(target=producer, daemon=True)
try:
with dpg.window(label="Worst Case", width=760, height=420), dpgg.gauge_grid(columns=4):
dpgg.analog_gauge(tag="rpm", label="RPM", max_value=8000, width=180, height=180)
dpgg.bar_gauge(tag="boost", label="Boost", max_value=30, width=180, height=90)
dpgg.level_gauge(tag="fuel", label="Fuel", width=120, height=180)
dpgg.battery_gauge(tag="battery", label="Battery", width=180, height=90)
dpg.create_viewport(title="dpg-gauges worst case", width=800, height=460)
dpg.setup_dearpygui()
thread.start()
dpg.set_frame_callback(1, gui_update)
dpg.show_viewport()
dpg.start_dearpygui()
finally:
stop.set()
thread.join(timeout=1.0)
dpg.destroy_context()
if __name__ == "__main__":
main()

25
examples/sizing.py Normal file
View File

@@ -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()

View File

@@ -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()

36
pyproject.toml Normal file
View File

@@ -0,0 +1,36 @@
[project]
name = "dpg-gauges"
version = "1.0.2"
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"

View File

@@ -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.2"
__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.")

View File

@@ -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

104
src/dpg_gauges/api.py Normal file
View File

@@ -0,0 +1,104 @@
"""Public API wrappers for logical gauge state."""
from __future__ import annotations
from typing import Any
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
from .widget import (
analog_gauge,
bar_gauge,
battery_gauge,
compass_gauge,
digital_gauge,
gauge_grid,
gauge_panel,
level_gauge,
multi_value_gauge,
segmented_gauge,
status_light,
thermometer_gauge,
)
__all__ = [
"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",
]
def configure(**config: Any) -> None:
"""Configure package-wide defaults for subsequently created gauges."""
configure_gauges(**config)
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__: tuple[type[GaugeConfig], ...] = (GaugeConfig,)

View File

@@ -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

View File

@@ -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()

View File

@@ -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."""

151
src/dpg_gauges/gauges.py Normal file
View File

@@ -0,0 +1,151 @@
"""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
parent: 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

0
src/dpg_gauges/py.typed Normal file
View File

691
src/dpg_gauges/renderer.py Normal file
View File

@@ -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 = 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))

145
src/dpg_gauges/scales.py Normal file
View File

@@ -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)

64
src/dpg_gauges/sizing.py Normal file
View File

@@ -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)))

View File

@@ -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

334
src/dpg_gauges/state.py Normal file
View File

@@ -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,
)

76
src/dpg_gauges/themes.py Normal file
View File

@@ -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)

107
src/dpg_gauges/types.py Normal file
View File

@@ -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]

647
src/dpg_gauges/widget.py Normal file
View File

@@ -0,0 +1,647 @@
"""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,
parent=config.parent 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,
parent: 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,
parent=parent 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,
parent: 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,
parent=parent or 0,
width=width,
height=height,
show=show,
horizontal=columns > 1,
**config,
)
return LayoutContext(container)
def analog_gauge(
*,
tag: Tag | None = None,
parent: 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,
parent: 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,
parent: 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,
parent: 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,
parent: 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,
parent: 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,
parent: 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,
parent: 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,
parent: 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,
parent: 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)

0
tests/.gitkeep Normal file
View File

117
tests/test_step1_core.py Normal file
View File

@@ -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")

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,101 @@
# pyright: reportGeneralTypeIssues=false
from collections.abc import Generator
import dearpygui.dearpygui as dpg
import pytest
import dpg_gauges as dpgg
from dpg_gauges.animation import advance_animation
from dpg_gauges.state import clear_registry, get_gauge_state, set_runtime_value
from dpg_gauges.types import AnimationConfig, SmoothingConfig
@pytest.fixture
def dpg_context() -> Generator[None]:
clear_registry()
dpgg.configure()
dpg.create_context()
try:
yield
finally:
clear_registry()
dpg.destroy_context()
@pytest.fixture(autouse=True)
def clean_registry() -> Generator[None]:
clear_registry()
dpgg.configure()
yield
clear_registry()
def test_animation_converges_to_latest_target() -> None:
dpgg.digital_gauge(tag="anim", value=0, animation=AnimationConfig(enabled=True, duration=1.0))
set_runtime_value("anim", 100, now=10.0)
state = get_gauge_state("anim")
config = state.config.animation
assert isinstance(config, AnimationConfig)
value, active = advance_animation(state.animation_state, config, now=10.5)
assert value == 50
assert active is True
value, active = advance_animation(state.animation_state, config, now=11.0)
assert value == 100
assert active is False
def test_smoothing_progresses_and_resets_on_range_change() -> None:
dpgg.digital_gauge(
tag="smooth",
value=0,
smoothing=SmoothingConfig(enabled=True, alpha=0.5),
)
set_runtime_value("smooth", 100, now=1.0)
state = get_gauge_state("smooth")
assert state.displayed_value == 0
assert state.smoothing_state.active is True
dpgg.configure_gauge("smooth", max_value=50)
assert state.displayed_value == 50
assert state.smoothing_state.value == 50
def test_latest_target_wins_without_queue_growth() -> None:
dpgg.digital_gauge(tag="latest", value=0, animation=AnimationConfig(enabled=True, duration=1.0))
set_runtime_value("latest", 25, now=1.0)
set_runtime_value("latest", 75, now=1.1)
set_runtime_value("latest", 50, now=1.2)
state = get_gauge_state("latest")
assert state.target_value == 50
assert state.clamped_value == 50
assert state.animation_state.target_value == 50
assert state.value_revision == 3
def test_attached_renderer_schedules_only_while_animation_active(dpg_context: None) -> None:
with dpg.window(label="Animation"):
dpgg.digital_gauge(
tag="attached",
value=0,
width=180,
height=80,
animation=AnimationConfig(enabled=True, duration=1.0),
)
dpgg.set_value("attached", 100)
debug = dpgg.get_gauge_debug_state("attached")
assert debug["renderer"]["scheduled"] is True
assert debug["animation"]["target_value"] == 100
state = get_gauge_state("attached")
state.animation_state.active = False
state.smoothing_state.active = False
state.renderer._scheduled = False
state.renderer.render()
assert dpgg.get_gauge_debug_state("attached")["renderer"]["scheduled"] is False

View File

@@ -0,0 +1,105 @@
# pyright: reportGeneralTypeIssues=false
from collections.abc import Generator
from importlib.resources import files
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.2"
assert files("dpg_gauges").joinpath("py.typed").is_file()
for name in dpgg.__all__:
assert hasattr(dpgg, name)
def test_gauge_creation_supports_explicit_parent(dpg_context: None) -> None:
dpg.add_window(tag="late_parent", label="Late Parent")
dpgg.analog_gauge(tag="late_gauge", parent="late_parent", width=180, height=180)
state = get_gauge_state("late_gauge")
assert state.config.parent == "late_parent"
assert dpg.get_item_parent(state.container_tag) == "late_parent"
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

168
uv.lock generated Normal file
View File

@@ -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.2"
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" },
]