step 7: harden docs and prepare beta

This commit is contained in:
2026-07-02 15:27:55 +02:00
parent f4716e0d19
commit ee5b60e620
13 changed files with 306 additions and 13 deletions

View File

@@ -2,11 +2,82 @@
Dear PyGui gauge widgets for dashboards and telemetry displays. Dear PyGui gauge widgets for dashboards and telemetry displays.
Current implementation status: Step 6 high-rate update support is implemented. Analog, digital, bar, ## Install
level, status, segmented, compass, thermometer, battery, and multi-value gauges render Dear PyGui
drawlist visuals. Bar/level gauges draw threshold bands and markers; analog gauges draw ticks,
redline bands, markers, and needles.
Runtime value updates are intended for the Dear PyGui GUI thread. Attached gauges redraw immediately ```bash
for simple value/configuration changes. Animation and smoothing are configurable per gauge; high-rate uv add dpg-gauges
examples marshal latest producer-thread values to GUI-thread frame callbacks. ```
For local development from another project:
```bash
uv add --editable ../dpg-gauges
```
## Minimal Example
```python
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
dpg.create_context()
with dpg.window(label="Dashboard"):
dpgg.analog_gauge(tag="rpm", label="RPM", value=3200, max_value=8000, width=220, height=220)
dpgg.digital_gauge(tag="speed", label="Speed", value=88, unit="km/h", width=220, height=90)
dpg.create_viewport(title="gauges", width=520, height=340)
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
dpg.destroy_context()
```
## Dashboard Example
```python
with dpg.window(label="Engine"):
with dpgg.gauge_panel(label="Powertrain", width=-1, height=260):
with dpgg.gauge_grid(columns=3):
dpgg.analog_gauge(tag="rpm", label="RPM", max_value=8000, redline_start=6500)
dpgg.bar_gauge(tag="boost", label="Boost", max_value=30, unit="psi")
dpgg.status_light(tag="ecu", label="ECU", state=True)
```
## GUI-Thread Contract
Dear PyGui item operations must happen on the GUI thread. Gauge creation and runtime updates such as
`set_value`, `update_gauge`, `set_thresholds`, and `delete_gauge` are GUI-thread functions. Worker
threads may produce data, but applications should marshal the latest values to a Dear PyGui frame
callback before calling dpg-gauges APIs.
## Animation And Smoothing
Animation changes the displayed value over a short duration without changing the raw target value.
Smoothing improves readability during high-rate updates.
```python
dpgg.digital_gauge(
tag="speed",
animation=dpgg.AnimationConfig(enabled=True, duration=0.15),
smoothing=dpgg.SmoothingConfig(enabled=True, alpha=0.25, max_step=5),
)
```
Disable animation with `animation=False` for immediate response.
## Thresholds And Markers
Thresholds and markers are visual only; they do not trigger callbacks or alarms.
```python
dpgg.bar_gauge(
tag="coolant",
min_value=40,
max_value=120,
thresholds=[dpgg.ThresholdBand(100, 120, (255, 45, 45, 150))],
markers=[dpgg.GaugeMarker(110, (255, 255, 255, 255), thickness=3)],
)
```
Implemented gauge types: analog, digital, bar, level, status light, segmented, compass,
thermometer, battery, and multi-value.

View File

@@ -2,7 +2,7 @@
## Current status ## Current status
Step 6 completed and verified with automated checks. Step 6 commit is pending. Step 7 completed and verified with automated checks. Step 7 commit and beta tag are pending.
## Completed steps ## Completed steps
@@ -30,6 +30,16 @@ Step 1 - Public API contract and pure core:
Step 7 - Documentation, hardening, and beta release. 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: Step 6 - High-rate updates, animation, and stress examples:
Implemented during Step 6: Implemented during Step 6:
@@ -103,7 +113,7 @@ Implemented during Step 2:
## Known issues ## Known issues
Manual visual/stress example checks for Steps 3, 4, 5, and 6 were not run in this headless session. Manual visual/stress example checks for Steps 3, 4, 5, 6, and 7 were not run in this headless session.
## Commands used ## Commands used
@@ -130,6 +140,16 @@ Manual visual/stress example checks for Steps 3, 4, 5, and 6 were not run in thi
- Ran `uv run ruff format .`. - Ran `uv run ruff format .`.
- Ran `uv run ruff format --check .`. - Ran `uv run ruff format --check .`.
- Ran `uv run pyright`. - 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. - 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. - 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. - Implemented Step 6 animation/smoothing scheduling refinements, high-rate examples, README status update, diagnostics fields, and tests.
@@ -162,4 +182,4 @@ Manual visual/stress example checks for Steps 3, 4, 5, and 6 were not run in thi
## Next action ## Next action
Commit Step 6, then implement Step 7. Commit Step 7 and tag `v0.1.0b1` if requested.

View File

41
docs/API_REFERENCE.md Normal file
View File

@@ -0,0 +1,41 @@
# API Reference
## Configuration
- `configure(**config)`: set package-wide defaults for later gauges.
## Gauge Creation
Creation functions are context managers and accept common options such as `tag`, `label`, `value`,
`min_value`, `max_value`, `unit`, `width`, `height`, `show`, `tooltip`, `callback`, `theme`,
`style`, `animation`, `smoothing`, `thresholds`, and `markers`.
- `analog_gauge(**config)`
- `digital_gauge(**config)`
- `bar_gauge(**config)`
- `level_gauge(**config)`
- `status_light(**config)`
- `segmented_gauge(**config)`
- `compass_gauge(**config)`
- `thermometer_gauge(**config)`
- `battery_gauge(**config)`
- `multi_value_gauge(**config)`
## Layout Helpers
- `gauge_panel(**config)`: child-window panel for grouped gauges.
- `gauge_grid(**config)`: lightweight horizontal gauge group.
## Runtime API
- `set_value(tag, value)`: set the latest target value.
- `get_value(tag)`: return the latest raw target value.
- `update_gauge(tag, **config)`: update value and/or configuration.
- `configure_gauge(tag, **config)`: update configuration.
- `set_thresholds(tag, thresholds)`: replace visual threshold bands.
- `set_markers(tag, markers)`: replace visual markers.
- `set_show(tag, show)`: show or hide a gauge.
- `delete_gauge(tag)`: remove a gauge.
- `get_gauge_debug_state(tag)`: return internal debug state.
Runtime functions are GUI-thread functions unless explicitly documented otherwise.

23
docs/EXAMPLES.md Normal file
View File

@@ -0,0 +1,23 @@
# Examples
Run examples with `uv run python examples/<name>.py`.
- `basic_analog.py`
- `basic_digital.py`
- `basic_bar.py`
- `basic_level.py`
- `basic_status.py`
- `basic_segmented.py`
- `basic_compass.py`
- `basic_thermometer.py`
- `basic_battery.py`
- `basic_multi_value.py`
- `thresholds_markers.py`
- `animations.py`
- `dashboard.py`
- `sizing.py`
- `live_high_rate.py`
- `live_worst_case.py`
The live examples use a producer thread for data generation and a Dear PyGui frame callback for all
gauge updates.

19
docs/GETTING_STARTED.md Normal file
View File

@@ -0,0 +1,19 @@
# Getting Started
Install with `uv add dpg-gauges`, then import as `import dpg_gauges as dpgg`.
Create gauges inside a Dear PyGui context:
```python
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
dpg.create_context()
with dpg.window(label="Dashboard"):
dpgg.digital_gauge(tag="speed", label="Speed", value=72, unit="km/h")
dpgg.set_value("speed", 88)
```
All widget creation and runtime updates are Dear PyGui GUI-thread operations. If telemetry is
produced in a worker thread, store the latest value and apply it from a GUI frame callback.

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "dpg-gauges" name = "dpg-gauges"
version = "0.1.0" version = "0.1.0b1"
description = "Add your description here" description = "Dear PyGui gauge widgets for dashboards and telemetry displays"
readme = "README.md" readme = "README.md"
authors = [ authors = [
{ name = "Hector van der Aa", email = "hector@h3cx.dev" } { name = "Hector van der Aa", email = "hector@h3cx.dev" }

View File

@@ -35,6 +35,8 @@ from .types import (
ThresholdBand, ThresholdBand,
) )
__version__ = "0.1.0b1"
__all__ = [ __all__ = [
"configure", "configure",
"GaugeTheme", "GaugeTheme",
@@ -67,8 +69,10 @@ __all__ = [
"set_show", "set_show",
"delete_gauge", "delete_gauge",
"get_gauge_debug_state", "get_gauge_debug_state",
"__version__",
] ]
def main() -> None: 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.") print("dpg-gauges is a library package; import dpg_gauges to use it.")

View File

@@ -20,6 +20,7 @@ from .types import GaugeMarker, GaugeValue, Tag, ThresholdBand
def configure(**config: Any) -> None: def configure(**config: Any) -> None:
"""Configure package-wide defaults for subsequently created gauges."""
configure_gauges(**config) configure_gauges(**config)
@@ -38,34 +39,42 @@ gauge_grid = _widget.gauge_grid
def set_value(tag: Tag, value: GaugeValue) -> None: def set_value(tag: Tag, value: GaugeValue) -> None:
"""Set a gauge target value from the Dear PyGui GUI thread."""
set_runtime_value(tag, value) set_runtime_value(tag, value)
def get_value(tag: Tag) -> GaugeValue: def get_value(tag: Tag) -> GaugeValue:
"""Return the latest raw target value assigned to a gauge."""
return get_runtime_value(tag) return get_runtime_value(tag)
def update_gauge(tag: Tag, **config: Any) -> None: def update_gauge(tag: Tag, **config: Any) -> None:
"""Update a gauge value and/or configuration fields."""
update_runtime_gauge(tag, **config) update_runtime_gauge(tag, **config)
def configure_gauge(tag: Tag, **config: Any) -> None: def configure_gauge(tag: Tag, **config: Any) -> None:
"""Update configuration fields for an existing gauge."""
update_runtime_gauge(tag, **config) update_runtime_gauge(tag, **config)
def set_thresholds(tag: Tag, thresholds: list[ThresholdBand] | tuple[ThresholdBand, ...]) -> None: 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) set_runtime_thresholds(tag, thresholds)
def set_markers(tag: Tag, markers: list[GaugeMarker] | tuple[GaugeMarker, ...]) -> None: def set_markers(tag: Tag, markers: list[GaugeMarker] | tuple[GaugeMarker, ...]) -> None:
"""Replace the visual markers for an existing gauge."""
set_runtime_markers(tag, markers) set_runtime_markers(tag, markers)
def set_show(tag: Tag, show: bool) -> None: def set_show(tag: Tag, show: bool) -> None:
"""Show or hide an existing gauge."""
set_runtime_show(tag, show) set_runtime_show(tag, show)
def delete_gauge(tag: Tag) -> None: def delete_gauge(tag: Tag) -> None:
"""Delete an existing gauge and its Dear PyGui shell when attached."""
delete_runtime_gauge(tag) delete_runtime_gauge(tag)

View File

@@ -9,6 +9,7 @@ from .types import Tag
def get_gauge_debug_state(tag: Tag) -> dict[str, Any]: def get_gauge_debug_state(tag: Tag) -> dict[str, Any]:
"""Return renderer and state diagnostics for an existing gauge."""
state = get_gauge_state(tag) state = get_gauge_state(tag)
debug = { debug = {
"tag": state.tag, "tag": state.tag,

View File

@@ -139,6 +139,7 @@ def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[Confi
def gauge_panel(**config: Any) -> LayoutContext: def gauge_panel(**config: Any) -> LayoutContext:
"""Create a lightweight child-window container for related gauges."""
if not _dpg_context_active: if not _dpg_context_active:
return LayoutContext(None) return LayoutContext(None)
tag = config.pop("tag", 0) tag = config.pop("tag", 0)
@@ -160,6 +161,7 @@ def gauge_panel(**config: Any) -> LayoutContext:
def gauge_grid(**config: Any) -> LayoutContext: def gauge_grid(**config: Any) -> LayoutContext:
"""Create a lightweight horizontal gauge layout container."""
if not _dpg_context_active: if not _dpg_context_active:
return LayoutContext(None) return LayoutContext(None)
columns = max(int(config.pop("columns", 1)), 1) columns = max(int(config.pop("columns", 1)), 1)
@@ -175,42 +177,52 @@ def gauge_grid(**config: Any) -> LayoutContext:
def analog_gauge(**config: Any) -> GaugeContext[AnalogGaugeConfig]: def analog_gauge(**config: Any) -> GaugeContext[AnalogGaugeConfig]:
"""Create an analog dial gauge with ticks, needle, thresholds, and markers."""
return create_gauge_context("analog", AnalogGaugeConfig(**config)) return create_gauge_context("analog", AnalogGaugeConfig(**config))
def digital_gauge(**config: Any) -> GaugeContext[DigitalGaugeConfig]: def digital_gauge(**config: Any) -> GaugeContext[DigitalGaugeConfig]:
"""Create a large numeric readout gauge."""
return create_gauge_context("digital", DigitalGaugeConfig(**config)) return create_gauge_context("digital", DigitalGaugeConfig(**config))
def bar_gauge(**config: Any) -> GaugeContext[BarGaugeConfig]: def bar_gauge(**config: Any) -> GaugeContext[BarGaugeConfig]:
"""Create a horizontal or vertical fill bar gauge."""
return create_gauge_context("bar", BarGaugeConfig(**config)) return create_gauge_context("bar", BarGaugeConfig(**config))
def level_gauge(**config: Any) -> GaugeContext[LevelGaugeConfig]: def level_gauge(**config: Any) -> GaugeContext[LevelGaugeConfig]:
"""Create a tank or level-style vertical gauge."""
return create_gauge_context("level", LevelGaugeConfig(**config)) return create_gauge_context("level", LevelGaugeConfig(**config))
def status_light(**config: Any) -> GaugeContext[StatusLightConfig]: def status_light(**config: Any) -> GaugeContext[StatusLightConfig]:
"""Create an LED-style status indicator gauge."""
return create_gauge_context("status_light", StatusLightConfig(**config)) return create_gauge_context("status_light", StatusLightConfig(**config))
def segmented_gauge(**config: Any) -> GaugeContext[SegmentedGaugeConfig]: def segmented_gauge(**config: Any) -> GaugeContext[SegmentedGaugeConfig]:
"""Create a segmented strip gauge for shift-light or cell-style displays."""
return create_gauge_context("segmented", SegmentedGaugeConfig(**config)) return create_gauge_context("segmented", SegmentedGaugeConfig(**config))
def compass_gauge(**config: Any) -> GaugeContext[CompassGaugeConfig]: def compass_gauge(**config: Any) -> GaugeContext[CompassGaugeConfig]:
"""Create a compass gauge that wraps numeric headings to 0-360 degrees."""
return create_gauge_context("compass", CompassGaugeConfig(**config)) return create_gauge_context("compass", CompassGaugeConfig(**config))
def thermometer_gauge(**config: Any) -> GaugeContext[ThermometerGaugeConfig]: def thermometer_gauge(**config: Any) -> GaugeContext[ThermometerGaugeConfig]:
"""Create a thermometer-style temperature gauge."""
return create_gauge_context("thermometer", ThermometerGaugeConfig(**config)) return create_gauge_context("thermometer", ThermometerGaugeConfig(**config))
def battery_gauge(**config: Any) -> GaugeContext[BatteryGaugeConfig]: def battery_gauge(**config: Any) -> GaugeContext[BatteryGaugeConfig]:
"""Create a battery-style percentage or value gauge."""
return create_gauge_context("battery", BatteryGaugeConfig(**config)) return create_gauge_context("battery", BatteryGaugeConfig(**config))
def multi_value_gauge(**config: Any) -> GaugeContext[MultiValueGaugeConfig]: def multi_value_gauge(**config: Any) -> GaugeContext[MultiValueGaugeConfig]:
"""Create a compact grouped key/value readout gauge."""
return create_gauge_context("multi_value", MultiValueGaugeConfig(**config)) return create_gauge_context("multi_value", MultiValueGaugeConfig(**config))

View File

@@ -0,0 +1,93 @@
# pyright: reportGeneralTypeIssues=false
from collections.abc import Generator
import dearpygui.dearpygui as dpg
import pytest
import dpg_gauges as dpgg
from dpg_gauges.exceptions import GaugeConfigError, GaugeNotFoundError
from dpg_gauges.gauges import DigitalGaugeConfig
from dpg_gauges.state import clear_registry, get_gauge_state, register_gauge
from dpg_gauges.types import AnimationConfig
@pytest.fixture(autouse=True)
def clean_registry() -> Generator[None]:
clear_registry()
dpgg.configure()
yield
clear_registry()
@pytest.fixture
def dpg_context() -> Generator[None]:
dpg.create_context()
try:
yield
finally:
dpg.destroy_context()
def test_public_exports_and_version() -> None:
assert dpgg.__version__ == "0.1.0b1"
for name in dpgg.__all__:
assert hasattr(dpgg, name)
def test_unknown_duplicate_and_deleted_gauge_errors() -> None:
with pytest.raises(GaugeNotFoundError):
dpgg.set_value("missing", 1)
dpgg.digital_gauge(tag="speed")
with pytest.raises(GaugeConfigError):
register_gauge("digital", DigitalGaugeConfig(tag="speed"))
dpgg.delete_gauge("speed")
with pytest.raises(GaugeNotFoundError):
dpgg.update_gauge("speed", value=2)
def test_invalid_range_threshold_marker_and_non_finite_value() -> None:
with pytest.raises(GaugeConfigError):
dpgg.digital_gauge(tag="bad", min_value=10, max_value=0)
dpgg.bar_gauge(tag="bar", min_value=0, max_value=100)
with pytest.raises(GaugeConfigError):
dpgg.set_thresholds("bar", [dpgg.ThresholdBand(float("nan"), 10, (255, 0, 0))])
with pytest.raises(GaugeConfigError):
dpgg.set_markers("bar", [dpgg.GaugeMarker(float("nan"), (255, 255, 255))])
dpgg.set_value("bar", float("inf"))
state = get_gauge_state("bar")
assert state.invalid_value is True
assert state.clamped_value is None
def test_hidden_layout_sizing_survives_show_later(dpg_context: None) -> None:
with dpg.window(label="Hidden"):
dpgg.digital_gauge(tag="hidden", value=1, width=180, height=80, show=False)
state = get_gauge_state("hidden")
assert state.last_nonzero_width == 180
assert state.last_nonzero_height == 80
dpgg.set_show("hidden", True)
assert state.config.show is True
assert state.last_nonzero_width == 180
def test_animation_disabled_and_enabled_modes() -> None:
dpgg.digital_gauge(tag="instant", value=0, animation=False)
dpgg.set_value("instant", 100)
instant = get_gauge_state("instant")
assert instant.animation_state.active is False
assert instant.displayed_value == 100
dpgg.digital_gauge(
tag="animated", value=0, animation=AnimationConfig(enabled=True, duration=1.0)
)
dpgg.set_value("animated", 100)
animated = get_gauge_state("animated")
assert animated.animation_state.active is True
assert animated.animation_state.target_value == 100

2
uv.lock generated
View File

@@ -36,7 +36,7 @@ wheels = [
[[package]] [[package]]
name = "dpg-gauges" name = "dpg-gauges"
version = "0.1.0" version = "0.1.0b1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dearpygui" }, { name = "dearpygui" },