Compare commits
21 Commits
44e4cbd246
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 72b6e3714f | |||
| c95ed3a239 | |||
| a8a6e1db19 | |||
| 5b1826d9c3 | |||
| 58ffcb8fb9 | |||
| 0a7b4f0172 | |||
| 5bd819d812 | |||
| 44403bea2b | |||
| accd1d1dee | |||
| ceba71927d | |||
| fe6670af9e | |||
| ac678e8dce | |||
| 63e79ebf8d | |||
| 5c40968fcc | |||
| 451557299e | |||
| ee5b60e620 | |||
| f4716e0d19 | |||
| 1a476bc91d | |||
| d638c512e9 | |||
| 41e44a23bc | |||
| 34fd60c34e |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.11
|
||||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# 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, min_column_width=180):
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
`gauge_grid` is a wrapping, table-backed layout. An integer `columns` value is a maximum; if the
|
||||||
|
parent is too narrow for `columns * min_column_width`, the grid uses fewer columns and continues on
|
||||||
|
the next row instead of creating a horizontal scrollbar. Use `columns="auto"` for autobreak mode,
|
||||||
|
where the grid derives the column count from the available width. Gauges with `width=0` or
|
||||||
|
`width=-1` fill their grid cell.
|
||||||
|
|
||||||
|
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.
|
||||||
154
codex/AGENTS.md
154
codex/AGENTS.md
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
48
docs/API_REFERENCE.md
Normal file
48
docs/API_REFERENCE.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 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(columns=1, min_column_width=180, fit_gauges=True, cell_padding=12, **config)`:
|
||||||
|
table-backed
|
||||||
|
wrapping gauge grid. Pass an integer `columns` to set the maximum column count; if the parent is
|
||||||
|
narrower than `columns * min_column_width`, the grid uses fewer columns and wraps gauges onto
|
||||||
|
later rows to avoid horizontal scrolling. Pass `columns="auto"` for autobreak mode, where the
|
||||||
|
column count is derived entirely from the available width and `min_column_width`. Gauges without
|
||||||
|
an explicit positive width fill their grid cell. Increase `cell_padding` if a custom Dear PyGui
|
||||||
|
theme uses wider table padding or borders.
|
||||||
|
|
||||||
|
## 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.
|
||||||
24
docs/EXAMPLES.md
Normal file
24
docs/EXAMPLES.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 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`
|
||||||
|
- `layout_grid.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
19
docs/GETTING_STARTED.md
Normal 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
0
examples/.gitkeep
Normal file
51
examples/animations.py
Normal file
51
examples/animations.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpg.create_context()
|
||||||
|
started = time.monotonic()
|
||||||
|
|
||||||
|
def update() -> None:
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
dpgg.set_value("animated", 50 + math.sin(elapsed * 2.0) * 50)
|
||||||
|
dpgg.set_value("smoothed", 50 + math.sin(elapsed * 7.0) * 50)
|
||||||
|
dpg.set_frame_callback(dpg.get_frame_count() + 1, update)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with dpg.window(label="Animations", width=460, height=260):
|
||||||
|
dpgg.digital_gauge(
|
||||||
|
tag="animated",
|
||||||
|
label="Animated",
|
||||||
|
value=0,
|
||||||
|
width=360,
|
||||||
|
height=90,
|
||||||
|
animation=dpgg.AnimationConfig(enabled=True, duration=0.35),
|
||||||
|
)
|
||||||
|
dpgg.bar_gauge(
|
||||||
|
tag="smoothed",
|
||||||
|
label="Smoothed",
|
||||||
|
value=0,
|
||||||
|
width=360,
|
||||||
|
height=90,
|
||||||
|
animation=False,
|
||||||
|
smoothing=dpgg.SmoothingConfig(enabled=True, alpha=0.18, max_step=8),
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges animations", width=500, height=300)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.set_frame_callback(1, update)
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
91
examples/autobreak_runtime.py
Normal file
91
examples/autobreak_runtime.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
COUNTERS: dict[str, int] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def add_runtime_gauges(grid: str, prefix: str, count: int = 6) -> None:
|
||||||
|
start = COUNTERS.get(grid, 0)
|
||||||
|
for offset in range(count):
|
||||||
|
index = start + offset
|
||||||
|
value = (index * 17 + 23) % 101
|
||||||
|
dpgg.digital_gauge(
|
||||||
|
tag=f"{prefix}_{index}",
|
||||||
|
parent=grid,
|
||||||
|
label=f"{prefix} {index + 1}",
|
||||||
|
value=value,
|
||||||
|
unit="%",
|
||||||
|
height=72,
|
||||||
|
)
|
||||||
|
COUNTERS[grid] = start + count
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
with dpg.window(label="Runtime autobreak tests", width=1220, height=900):
|
||||||
|
dpg.add_text("Fixed panel, runtime gauges: expected 5 columns at width=1000")
|
||||||
|
with dpgg.gauge_panel(label="Fixed 1000 x 230", width=1000, height=230):
|
||||||
|
dpgg.gauge_grid(tag="fixed_runtime_grid", columns="auto", min_column_width=180)
|
||||||
|
dpg.add_button(
|
||||||
|
label="Add fixed gauges",
|
||||||
|
callback=lambda: add_runtime_gauges("fixed_runtime_grid", "fixed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.add_spacer(height=10)
|
||||||
|
dpg.add_text("Autosize panel with configured size: should match fixed panel")
|
||||||
|
with dpgg.gauge_panel(
|
||||||
|
label="Autosize configured 1000 x 230",
|
||||||
|
width=1000,
|
||||||
|
height=230,
|
||||||
|
autosize_x=True,
|
||||||
|
autosize_y=True,
|
||||||
|
):
|
||||||
|
dpgg.gauge_grid(tag="autosize_runtime_grid", columns="auto", min_column_width=180)
|
||||||
|
dpg.add_button(
|
||||||
|
label="Add autosize gauges",
|
||||||
|
callback=lambda: add_runtime_gauges("autosize_runtime_grid", "auto"),
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.add_spacer(height=10)
|
||||||
|
dpg.add_text("Autosize panel without width: grid falls back to the owning window width")
|
||||||
|
with dpgg.gauge_panel(label="Autosize inherited width", height=230, autosize_x=True):
|
||||||
|
dpgg.gauge_grid(tag="inherited_runtime_grid", columns="auto", min_column_width=180)
|
||||||
|
dpg.add_button(
|
||||||
|
label="Add inherited-width gauges",
|
||||||
|
callback=lambda: add_runtime_gauges("inherited_runtime_grid", "inherited"),
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.add_spacer(height=10)
|
||||||
|
dpg.add_text("Narrow autosize panel: expected 2 columns at width=430")
|
||||||
|
with dpgg.gauge_panel(
|
||||||
|
label="Narrow autosize 430 x 210",
|
||||||
|
width=430,
|
||||||
|
height=210,
|
||||||
|
autosize_x=True,
|
||||||
|
autosize_y=True,
|
||||||
|
):
|
||||||
|
dpgg.gauge_grid(tag="narrow_runtime_grid", columns="auto", min_column_width=180)
|
||||||
|
dpg.add_button(
|
||||||
|
label="Add narrow gauges",
|
||||||
|
callback=lambda: add_runtime_gauges("narrow_runtime_grid", "narrow"),
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.set_frame_callback(2, lambda: add_runtime_gauges("fixed_runtime_grid", "fixed"))
|
||||||
|
dpg.set_frame_callback(3, lambda: add_runtime_gauges("autosize_runtime_grid", "auto"))
|
||||||
|
dpg.set_frame_callback(4, lambda: add_runtime_gauges("inherited_runtime_grid", "inherited"))
|
||||||
|
dpg.set_frame_callback(5, lambda: add_runtime_gauges("narrow_runtime_grid", "narrow"))
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges runtime autobreak", width=1260, height=940)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
25
examples/basic_analog.py
Normal file
25
examples/basic_analog.py
Normal 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
33
examples/basic_bar.py
Normal 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
25
examples/basic_battery.py
Normal 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
23
examples/basic_compass.py
Normal 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
25
examples/basic_digital.py
Normal 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
30
examples/basic_level.py
Normal 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()
|
||||||
29
examples/basic_multi_value.py
Normal file
29
examples/basic_multi_value.py
Normal 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()
|
||||||
32
examples/basic_segmented.py
Normal file
32
examples/basic_segmented.py
Normal 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
35
examples/basic_status.py
Normal 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()
|
||||||
32
examples/basic_thermometer.py
Normal file
32
examples/basic_thermometer.py
Normal 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
35
examples/dashboard.py
Normal 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()
|
||||||
65
examples/layout_grid.py
Normal file
65
examples/layout_grid.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
with dpg.window(label="Grid layout", width=820, height=620):
|
||||||
|
dpg.add_text("Max columns: columns=3 wraps onto new rows after every third gauge")
|
||||||
|
with dpgg.gauge_panel(label="Wide panel", width=-1, height=270), dpgg.gauge_grid(
|
||||||
|
columns=3, min_column_width=190
|
||||||
|
):
|
||||||
|
dpgg.analog_gauge(
|
||||||
|
tag="grid_rpm", label="RPM", value=4200, max_value=8000, height=190
|
||||||
|
)
|
||||||
|
dpgg.digital_gauge(
|
||||||
|
tag="grid_speed", label="Speed", value=88, unit="km/h", height=90
|
||||||
|
)
|
||||||
|
dpgg.bar_gauge(
|
||||||
|
tag="grid_boost", label="Boost", value=12.5, max_value=30, unit="psi"
|
||||||
|
)
|
||||||
|
dpgg.status_light(tag="grid_ecu", label="ECU", state=True, height=70)
|
||||||
|
dpgg.battery_gauge(tag="grid_battery", label="Battery", value=74, height=160)
|
||||||
|
|
||||||
|
dpg.add_spacer(height=8)
|
||||||
|
dpg.add_text("Autobreak: columns='auto' chooses columns from available width")
|
||||||
|
with (
|
||||||
|
dpgg.gauge_panel(label="Autobreak narrow panel", width=430, height=285),
|
||||||
|
dpgg.gauge_grid(columns="auto", min_column_width=180),
|
||||||
|
):
|
||||||
|
for index, value in enumerate((18, 42, 67, 91, 55), start=1):
|
||||||
|
dpgg.digital_gauge(
|
||||||
|
tag=f"narrow_{index}",
|
||||||
|
label=f"Metric {index}",
|
||||||
|
value=value,
|
||||||
|
unit="%",
|
||||||
|
height=72,
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.add_spacer(height=8)
|
||||||
|
dpg.add_text("Panels can also occupy grid cells")
|
||||||
|
with dpgg.gauge_grid(columns=2, min_column_width=240):
|
||||||
|
with dpgg.gauge_panel(label="Left cell", height=120):
|
||||||
|
dpgg.segmented_gauge(tag="left_segments", label="Load", value=70, height=80)
|
||||||
|
with dpgg.gauge_panel(label="Right cell", height=120):
|
||||||
|
dpgg.multi_value_gauge(
|
||||||
|
tag="right_values",
|
||||||
|
label="Sensors",
|
||||||
|
values={"oil": 82, "fuel": 64, "air": 31},
|
||||||
|
height=92,
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges grid layout", width=860, height=660)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
73
examples/live_high_rate.py
Normal file
73
examples/live_high_rate.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
latest = {"rpm": 0.0, "speed": 0.0}
|
||||||
|
lock = threading.Lock()
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
def producer() -> None:
|
||||||
|
started = time.monotonic()
|
||||||
|
while not stop.is_set():
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
with lock:
|
||||||
|
latest["rpm"] = 4000 + math.sin(elapsed * 9.0) * 3500
|
||||||
|
latest["speed"] = 120 + math.sin(elapsed * 3.0) * 80
|
||||||
|
time.sleep(1 / 240)
|
||||||
|
|
||||||
|
def gui_update() -> None:
|
||||||
|
with lock:
|
||||||
|
rpm = latest["rpm"]
|
||||||
|
speed = latest["speed"]
|
||||||
|
dpgg.set_value("rpm", rpm)
|
||||||
|
dpgg.set_value("speed", speed)
|
||||||
|
dpg.set_frame_callback(dpg.get_frame_count() + 1, gui_update)
|
||||||
|
|
||||||
|
dpg.create_context()
|
||||||
|
thread = threading.Thread(target=producer, daemon=True)
|
||||||
|
try:
|
||||||
|
with dpg.window(label="High Rate", width=620, height=360):
|
||||||
|
dpgg.analog_gauge(
|
||||||
|
tag="rpm",
|
||||||
|
label="RPM",
|
||||||
|
value=0,
|
||||||
|
max_value=8000,
|
||||||
|
redline_start=6500,
|
||||||
|
width=240,
|
||||||
|
height=240,
|
||||||
|
animation=False,
|
||||||
|
smoothing=dpgg.SmoothingConfig(enabled=True, alpha=0.22, max_step=500),
|
||||||
|
)
|
||||||
|
dpgg.digital_gauge(
|
||||||
|
tag="speed",
|
||||||
|
label="Speed",
|
||||||
|
value=0,
|
||||||
|
max_value=240,
|
||||||
|
unit="km/h",
|
||||||
|
width=260,
|
||||||
|
height=90,
|
||||||
|
animation=dpgg.AnimationConfig(enabled=True, duration=0.12),
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges high rate", width=660, height=400)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
thread.start()
|
||||||
|
dpg.set_frame_callback(1, gui_update)
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
thread.join(timeout=1.0)
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
58
examples/live_worst_case.py
Normal file
58
examples/live_worst_case.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
latest = {"rpm": 0.0, "boost": 0.0, "fuel": 0.0, "battery": 0.0}
|
||||||
|
lock = threading.Lock()
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
def producer() -> None:
|
||||||
|
started = time.monotonic()
|
||||||
|
while not stop.is_set():
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
with lock:
|
||||||
|
latest["rpm"] = random.uniform(0, 8000)
|
||||||
|
latest["boost"] = 15 + math.sin(elapsed * 25.0) * 15
|
||||||
|
latest["fuel"] = 50 + math.sin(elapsed * 4.0) * 50
|
||||||
|
latest["battery"] = random.uniform(0, 100)
|
||||||
|
time.sleep(1 / 500)
|
||||||
|
|
||||||
|
def gui_update() -> None:
|
||||||
|
with lock:
|
||||||
|
snapshot = dict(latest)
|
||||||
|
for tag, value in snapshot.items():
|
||||||
|
dpgg.set_value(tag, value)
|
||||||
|
dpg.set_frame_callback(dpg.get_frame_count() + 1, gui_update)
|
||||||
|
|
||||||
|
dpg.create_context()
|
||||||
|
thread = threading.Thread(target=producer, daemon=True)
|
||||||
|
try:
|
||||||
|
with dpg.window(label="Worst Case", width=760, height=420), dpgg.gauge_grid(columns=4):
|
||||||
|
dpgg.analog_gauge(tag="rpm", label="RPM", max_value=8000, width=180, height=180)
|
||||||
|
dpgg.bar_gauge(tag="boost", label="Boost", max_value=30, width=180, height=90)
|
||||||
|
dpgg.level_gauge(tag="fuel", label="Fuel", width=120, height=180)
|
||||||
|
dpgg.battery_gauge(tag="battery", label="Battery", width=180, height=90)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges worst case", width=800, height=460)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
thread.start()
|
||||||
|
dpg.set_frame_callback(1, gui_update)
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
thread.join(timeout=1.0)
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
62
examples/reconnect_grid.py
Normal file
62
examples/reconnect_grid.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
GRID = "connector_grid"
|
||||||
|
ACTIVE_TAGS: list[str] = []
|
||||||
|
CYCLE = 0
|
||||||
|
|
||||||
|
|
||||||
|
def connect_connector() -> None:
|
||||||
|
global CYCLE
|
||||||
|
if ACTIVE_TAGS:
|
||||||
|
return
|
||||||
|
for index in range(5):
|
||||||
|
tag = f"connector_{CYCLE}_{index}"
|
||||||
|
ACTIVE_TAGS.append(tag)
|
||||||
|
dpgg.digital_gauge(
|
||||||
|
tag=tag,
|
||||||
|
parent=GRID,
|
||||||
|
label=f"Conn {CYCLE}.{index}",
|
||||||
|
value=(CYCLE * 13 + index * 17) % 100,
|
||||||
|
unit="%",
|
||||||
|
height=72,
|
||||||
|
)
|
||||||
|
CYCLE += 1
|
||||||
|
|
||||||
|
|
||||||
|
def disconnect_connector() -> None:
|
||||||
|
while ACTIVE_TAGS:
|
||||||
|
dpgg.delete_gauge(ACTIVE_TAGS.pop())
|
||||||
|
|
||||||
|
|
||||||
|
def reconnect_connector() -> None:
|
||||||
|
disconnect_connector()
|
||||||
|
connect_connector()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
with dpg.window(label="Reconnect grid test", width=860, height=420):
|
||||||
|
dpg.add_text("Reconnect should always start at the first grid cell.")
|
||||||
|
with dpgg.gauge_panel(label="Connector gauges", width=820, height=230):
|
||||||
|
dpgg.gauge_grid(tag=GRID, columns="auto", min_column_width=180)
|
||||||
|
with dpg.group(horizontal=True):
|
||||||
|
dpg.add_button(label="Connect", callback=connect_connector)
|
||||||
|
dpg.add_button(label="Disconnect", callback=disconnect_connector)
|
||||||
|
dpg.add_button(label="Reconnect", callback=reconnect_connector)
|
||||||
|
|
||||||
|
dpg.set_frame_callback(2, connect_connector)
|
||||||
|
dpg.create_viewport(title="dpg-gauges reconnect grid", width=900, height=460)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
25
examples/sizing.py
Normal file
25
examples/sizing.py
Normal 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()
|
||||||
37
examples/thresholds_markers.py
Normal file
37
examples/thresholds_markers.py
Normal 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
36
pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[project]
|
||||||
|
name = "dpg-gauges"
|
||||||
|
version = "1.1.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"
|
||||||
78
src/dpg_gauges/__init__.py
Normal file
78
src/dpg_gauges/__init__.py
Normal 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.1.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.")
|
||||||
72
src/dpg_gauges/animation.py
Normal file
72
src/dpg_gauges/animation.py
Normal 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
104
src/dpg_gauges/api.py
Normal 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,)
|
||||||
43
src/dpg_gauges/diagnostics.py
Normal file
43
src/dpg_gauges/diagnostics.py
Normal 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
|
||||||
44
src/dpg_gauges/draw_layers.py
Normal file
44
src/dpg_gauges/draw_layers.py
Normal 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()
|
||||||
25
src/dpg_gauges/exceptions.py
Normal file
25
src/dpg_gauges/exceptions.py
Normal 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
151
src/dpg_gauges/gauges.py
Normal 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
0
src/dpg_gauges/py.typed
Normal file
694
src/dpg_gauges/renderer.py
Normal file
694
src/dpg_gauges/renderer.py
Normal file
@@ -0,0 +1,694 @@
|
|||||||
|
"""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) -> Tag | None:
|
||||||
|
if not dpg.does_item_exist(self.state.container_tag):
|
||||||
|
return None
|
||||||
|
parent = dpg.get_item_parent(self.state.container_tag)
|
||||||
|
dpg.delete_item(self.state.container_tag)
|
||||||
|
return cast(Tag | None, parent)
|
||||||
|
|
||||||
|
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
145
src/dpg_gauges/scales.py
Normal 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
64
src/dpg_gauges/sizing.py
Normal 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)))
|
||||||
45
src/dpg_gauges/smoothing.py
Normal file
45
src/dpg_gauges/smoothing.py
Normal 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
|
||||||
339
src/dpg_gauges/state.py
Normal file
339
src/dpg_gauges/state.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
"""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)
|
||||||
|
layout_parent: Tag | None = None
|
||||||
|
if state.renderer is not None:
|
||||||
|
layout_parent = state.renderer.delete()
|
||||||
|
del _registry[tag]
|
||||||
|
if layout_parent is not None:
|
||||||
|
from .widget import _cleanup_grid_after_item_delete
|
||||||
|
|
||||||
|
_cleanup_grid_after_item_delete(layout_parent)
|
||||||
|
|
||||||
|
|
||||||
|
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
76
src/dpg_gauges/themes.py
Normal 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
107
src/dpg_gauges/types.py
Normal 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]
|
||||||
1033
src/dpg_gauges/widget.py
Normal file
1033
src/dpg_gauges/widget.py
Normal file
File diff suppressed because it is too large
Load Diff
0
tests/.gitkeep
Normal file
0
tests/.gitkeep
Normal file
117
tests/test_step1_core.py
Normal file
117
tests/test_step1_core.py
Normal 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")
|
||||||
162
tests/test_step2_state_model.py
Normal file
162
tests/test_step2_state_model.py
Normal 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"]
|
||||||
266
tests/test_step3_widget_renderer.py
Normal file
266
tests/test_step3_widget_renderer.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
from dpg_gauges.state import clear_registry, get_gauge_state
|
||||||
|
|
||||||
|
|
||||||
|
def _children(item: int | str) -> dict[int, list[int | str]]:
|
||||||
|
return cast(dict[int, list[int | str]], dpg.get_item_children(item))
|
||||||
|
|
||||||
|
|
||||||
|
def _occupied_cell_counts(table: int | str) -> list[int]:
|
||||||
|
counts: list[int] = []
|
||||||
|
for row in _children(table)[1]:
|
||||||
|
occupied = 0
|
||||||
|
for cell in _children(row)[1]:
|
||||||
|
if any(dpg.does_item_exist(child) for child in _children(cell)[1]):
|
||||||
|
occupied += 1
|
||||||
|
counts.append(occupied)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_columns_wrap_gauges_into_rows(dpg_context: None) -> None:
|
||||||
|
with dpg.window(label="Dashboard", width=720), dpgg.gauge_grid(
|
||||||
|
tag="wrap_grid", columns=3, min_column_width=1
|
||||||
|
):
|
||||||
|
for index in range(5):
|
||||||
|
dpgg.digital_gauge(tag=f"metric_{index}", value=index, height=60)
|
||||||
|
|
||||||
|
children = _children("wrap_grid")
|
||||||
|
assert len(children[0]) == 3
|
||||||
|
assert len(children[1]) == 2
|
||||||
|
assert [len(_children(row)[1]) for row in children[1]] == [3, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_auto_reduces_columns_to_parent_width(dpg_context: None) -> None:
|
||||||
|
with dpg.window(label="Narrow", width=380), dpgg.gauge_grid(
|
||||||
|
tag="narrow_grid", columns=3, min_column_width=180
|
||||||
|
):
|
||||||
|
for index in range(5):
|
||||||
|
dpgg.digital_gauge(tag=f"narrow_metric_{index}", value=index, height=60)
|
||||||
|
|
||||||
|
children = _children("narrow_grid")
|
||||||
|
assert len(children[0]) == 2
|
||||||
|
assert len(children[1]) == 3
|
||||||
|
assert [len(_children(row)[1]) for row in children[1]] == [2, 2, 1]
|
||||||
|
assert 0 < get_gauge_state("narrow_metric_0").config.width < 190
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_auto_columns_uses_available_width(dpg_context: None) -> None:
|
||||||
|
with dpg.window(label="Auto", width=640), dpgg.gauge_grid(
|
||||||
|
tag="auto_grid", columns="auto", min_column_width=180
|
||||||
|
):
|
||||||
|
for index in range(7):
|
||||||
|
dpgg.digital_gauge(tag=f"auto_metric_{index}", value=index, height=60)
|
||||||
|
|
||||||
|
children = _children("auto_grid")
|
||||||
|
assert len(children[0]) == 3
|
||||||
|
assert len(children[1]) == 3
|
||||||
|
assert [len(_children(row)[1]) for row in children[1]] == [3, 3, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_routes_panels_without_stealing_panel_children(dpg_context: None) -> None:
|
||||||
|
with dpg.window(label="Nested", width=520), dpgg.gauge_grid(tag="nested_grid", columns=2):
|
||||||
|
with dpgg.gauge_panel(tag="nested_panel", height=120):
|
||||||
|
dpgg.digital_gauge(tag="inside_panel", value=1, height=60)
|
||||||
|
dpgg.digital_gauge(tag="beside_panel", value=2, height=60)
|
||||||
|
|
||||||
|
panel_cell = cast(int | str, dpg.get_item_parent("nested_panel"))
|
||||||
|
assert dpg.get_item_type(panel_cell) == "mvAppItemType::mvTableCell"
|
||||||
|
assert dpg.get_item_parent("inside_panel") == "nested_panel"
|
||||||
|
beside_cell = cast(int | str, dpg.get_item_parent("beside_panel"))
|
||||||
|
assert dpg.get_item_type(beside_cell) == "mvAppItemType::mvTableCell"
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_accepts_gauges_added_after_context(dpg_context: None) -> None:
|
||||||
|
with dpg.window(label="Late", width=520), dpgg.gauge_grid(tag="late_grid", columns=2):
|
||||||
|
dpgg.digital_gauge(tag="late_initial", value=1, height=60)
|
||||||
|
|
||||||
|
dpgg.digital_gauge(tag="late_second", parent="late_grid", value=2, height=60)
|
||||||
|
dpgg.digital_gauge(tag="late_third", parent="late_grid", value=3, height=60)
|
||||||
|
|
||||||
|
children = _children("late_grid")
|
||||||
|
assert [len(_children(row)[1]) for row in children[1]] == [2, 1]
|
||||||
|
|
||||||
|
for tag in ("late_initial", "late_second", "late_third"):
|
||||||
|
cell = cast(int | str, dpg.get_item_parent(tag))
|
||||||
|
assert dpg.get_item_type(cell) == "mvAppItemType::mvTableCell"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_grid_in_autosize_panel_wraps_late_gauges(dpg_context: None) -> None:
|
||||||
|
with (
|
||||||
|
dpg.window(label="Autosize", width=1200),
|
||||||
|
dpgg.gauge_panel(
|
||||||
|
tag="autosize_panel", width=1000, height=600, autosize_x=True, autosize_y=True
|
||||||
|
),
|
||||||
|
):
|
||||||
|
dpgg.gauge_grid(tag="autosize_grid", columns="auto", min_column_width=180)
|
||||||
|
|
||||||
|
for index in range(7):
|
||||||
|
dpgg.digital_gauge(
|
||||||
|
tag=f"autosize_metric_{index}", parent="autosize_grid", value=index, height=60
|
||||||
|
)
|
||||||
|
|
||||||
|
children = _children("autosize_grid")
|
||||||
|
assert len(children[0]) == 5
|
||||||
|
assert [len(_children(row)[1]) for row in children[1]] == [5, 2]
|
||||||
|
assert 0 < get_gauge_state("autosize_metric_0").config.width < 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_grid_refreshes_when_autosize_width_becomes_known(dpg_context: None) -> None:
|
||||||
|
with (
|
||||||
|
dpg.window(label="Autosize"),
|
||||||
|
dpgg.gauge_panel(tag="late_width_panel", autosize_x=True, autosize_y=True),
|
||||||
|
):
|
||||||
|
grid = dpgg.gauge_grid(tag="late_width_grid", columns="auto", min_column_width=180)
|
||||||
|
|
||||||
|
assert grid.columns == 1
|
||||||
|
|
||||||
|
dpg.configure_item("late_width_panel", width=1000)
|
||||||
|
for index in range(6):
|
||||||
|
dpgg.digital_gauge(tag=f"late_width_metric_{index}", parent="late_width_grid", value=index)
|
||||||
|
|
||||||
|
children = _children("late_width_grid")
|
||||||
|
assert len(children[0]) == 5
|
||||||
|
assert [len(_children(row)[1]) for row in children[1]] == [5, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_grid_reflows_existing_gauges_when_panel_narrows(dpg_context: None) -> None:
|
||||||
|
with dpg.window(label="Resize"), dpgg.gauge_panel(
|
||||||
|
tag="resize_panel", width=1000, height=300
|
||||||
|
):
|
||||||
|
grid = dpgg.gauge_grid(tag="resize_grid", columns="auto", min_column_width=180)
|
||||||
|
with grid:
|
||||||
|
for index in range(6):
|
||||||
|
dpgg.digital_gauge(tag=f"resize_metric_{index}", value=index, height=60)
|
||||||
|
|
||||||
|
assert grid.columns == 5
|
||||||
|
children = _children("resize_grid")
|
||||||
|
assert [len(_children(row)[1]) for row in children[1]] == [5, 1]
|
||||||
|
|
||||||
|
dpg.configure_item("resize_panel", width=430)
|
||||||
|
grid.refresh_columns()
|
||||||
|
|
||||||
|
assert grid.columns == 2
|
||||||
|
children = _children("resize_grid")
|
||||||
|
assert len(children[0]) == 2
|
||||||
|
assert [len(_children(row)[1]) for row in children[1]] == [2, 2, 2]
|
||||||
|
for index in range(6):
|
||||||
|
cell = cast(int | str, dpg.get_item_parent(f"resize_metric_{index}"))
|
||||||
|
assert dpg.get_item_type(cell) == "mvAppItemType::mvTableCell"
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_compacts_after_runtime_gauge_delete(dpg_context: None) -> None:
|
||||||
|
from dpg_gauges.widget import _cleanup_grid_now
|
||||||
|
|
||||||
|
with dpg.window(label="Reconnect", width=800):
|
||||||
|
dpgg.gauge_grid(tag="connector_grid", columns=3, min_column_width=1)
|
||||||
|
|
||||||
|
for cycle in range(3):
|
||||||
|
tags = [f"connector_{cycle}_{index}" for index in range(3)]
|
||||||
|
for index, tag in enumerate(tags):
|
||||||
|
dpgg.digital_gauge(tag=tag, parent="connector_grid", value=index, height=60)
|
||||||
|
|
||||||
|
children = _children("connector_grid")
|
||||||
|
assert [len(_children(row)[1]) for row in children[1]] == [3]
|
||||||
|
assert _occupied_cell_counts("connector_grid") == [3]
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
dpgg.delete_gauge(tag)
|
||||||
|
|
||||||
|
_cleanup_grid_now("connector_grid")
|
||||||
|
children = _children("connector_grid")
|
||||||
|
assert children[1] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_reuses_deleted_cell_before_appending(dpg_context: None) -> None:
|
||||||
|
with dpg.window(label="Partial", width=800):
|
||||||
|
dpgg.gauge_grid(tag="partial_grid", columns=3, min_column_width=1)
|
||||||
|
|
||||||
|
for index in range(3):
|
||||||
|
dpgg.digital_gauge(tag=f"partial_{index}", parent="partial_grid", value=index, height=60)
|
||||||
|
|
||||||
|
dpgg.delete_gauge("partial_1")
|
||||||
|
|
||||||
|
assert _occupied_cell_counts("partial_grid") == [2]
|
||||||
|
|
||||||
|
dpgg.digital_gauge(tag="partial_new", parent="partial_grid", value=99, height=60)
|
||||||
|
|
||||||
|
children = _children("partial_grid")
|
||||||
|
assert [len(_children(row)[1]) for row in children[1]] == [3]
|
||||||
|
assert _occupied_cell_counts("partial_grid") == [3]
|
||||||
88
tests/test_step4_simple_gauges.py
Normal file
88
tests/test_step4_simple_gauges.py
Normal 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
|
||||||
91
tests/test_step5_catalogue.py
Normal file
91
tests/test_step5_catalogue.py
Normal 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
|
||||||
101
tests/test_step6_animation_stress.py
Normal file
101
tests/test_step6_animation_stress.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
from dpg_gauges.animation import advance_animation
|
||||||
|
from dpg_gauges.state import clear_registry, get_gauge_state, set_runtime_value
|
||||||
|
from dpg_gauges.types import AnimationConfig, SmoothingConfig
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dpg_context() -> Generator[None]:
|
||||||
|
clear_registry()
|
||||||
|
dpgg.configure()
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
clear_registry()
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clean_registry() -> Generator[None]:
|
||||||
|
clear_registry()
|
||||||
|
dpgg.configure()
|
||||||
|
yield
|
||||||
|
clear_registry()
|
||||||
|
|
||||||
|
|
||||||
|
def test_animation_converges_to_latest_target() -> None:
|
||||||
|
dpgg.digital_gauge(tag="anim", value=0, animation=AnimationConfig(enabled=True, duration=1.0))
|
||||||
|
|
||||||
|
set_runtime_value("anim", 100, now=10.0)
|
||||||
|
state = get_gauge_state("anim")
|
||||||
|
config = state.config.animation
|
||||||
|
assert isinstance(config, AnimationConfig)
|
||||||
|
value, active = advance_animation(state.animation_state, config, now=10.5)
|
||||||
|
assert value == 50
|
||||||
|
assert active is True
|
||||||
|
|
||||||
|
value, active = advance_animation(state.animation_state, config, now=11.0)
|
||||||
|
assert value == 100
|
||||||
|
assert active is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_smoothing_progresses_and_resets_on_range_change() -> None:
|
||||||
|
dpgg.digital_gauge(
|
||||||
|
tag="smooth",
|
||||||
|
value=0,
|
||||||
|
smoothing=SmoothingConfig(enabled=True, alpha=0.5),
|
||||||
|
)
|
||||||
|
|
||||||
|
set_runtime_value("smooth", 100, now=1.0)
|
||||||
|
state = get_gauge_state("smooth")
|
||||||
|
assert state.displayed_value == 0
|
||||||
|
assert state.smoothing_state.active is True
|
||||||
|
|
||||||
|
dpgg.configure_gauge("smooth", max_value=50)
|
||||||
|
assert state.displayed_value == 50
|
||||||
|
assert state.smoothing_state.value == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_target_wins_without_queue_growth() -> None:
|
||||||
|
dpgg.digital_gauge(tag="latest", value=0, animation=AnimationConfig(enabled=True, duration=1.0))
|
||||||
|
|
||||||
|
set_runtime_value("latest", 25, now=1.0)
|
||||||
|
set_runtime_value("latest", 75, now=1.1)
|
||||||
|
set_runtime_value("latest", 50, now=1.2)
|
||||||
|
|
||||||
|
state = get_gauge_state("latest")
|
||||||
|
assert state.target_value == 50
|
||||||
|
assert state.clamped_value == 50
|
||||||
|
assert state.animation_state.target_value == 50
|
||||||
|
assert state.value_revision == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_attached_renderer_schedules_only_while_animation_active(dpg_context: None) -> None:
|
||||||
|
with dpg.window(label="Animation"):
|
||||||
|
dpgg.digital_gauge(
|
||||||
|
tag="attached",
|
||||||
|
value=0,
|
||||||
|
width=180,
|
||||||
|
height=80,
|
||||||
|
animation=AnimationConfig(enabled=True, duration=1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
dpgg.set_value("attached", 100)
|
||||||
|
debug = dpgg.get_gauge_debug_state("attached")
|
||||||
|
assert debug["renderer"]["scheduled"] is True
|
||||||
|
assert debug["animation"]["target_value"] == 100
|
||||||
|
|
||||||
|
state = get_gauge_state("attached")
|
||||||
|
state.animation_state.active = False
|
||||||
|
state.smoothing_state.active = False
|
||||||
|
state.renderer._scheduled = False
|
||||||
|
state.renderer.render()
|
||||||
|
assert dpgg.get_gauge_debug_state("attached")["renderer"]["scheduled"] is False
|
||||||
105
tests/test_step7_hardening.py
Normal file
105
tests/test_step7_hardening.py
Normal 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.1.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
168
uv.lock
generated
Normal 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.1.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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user