diff --git a/README.md b/README.md index 008b6f3..2815472 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,82 @@ Dear PyGui gauge widgets for dashboards and telemetry displays. -Current implementation status: Step 6 high-rate update support is implemented. Analog, digital, bar, -level, status, segmented, compass, thermometer, battery, and multi-value gauges render Dear PyGui -drawlist visuals. Bar/level gauges draw threshold bands and markers; analog gauges draw ticks, -redline bands, markers, and needles. +## Install -Runtime value updates are intended for the Dear PyGui GUI thread. Attached gauges redraw immediately -for simple value/configuration changes. Animation and smoothing are configurable per gauge; high-rate -examples marshal latest producer-thread values to GUI-thread frame callbacks. +```bash +uv add dpg-gauges +``` + +For local development from another project: + +```bash +uv add --editable ../dpg-gauges +``` + +## Minimal Example + +```python +import dearpygui.dearpygui as dpg +import dpg_gauges as dpgg + +dpg.create_context() +with dpg.window(label="Dashboard"): + dpgg.analog_gauge(tag="rpm", label="RPM", value=3200, max_value=8000, width=220, height=220) + dpgg.digital_gauge(tag="speed", label="Speed", value=88, unit="km/h", width=220, height=90) + +dpg.create_viewport(title="gauges", width=520, height=340) +dpg.setup_dearpygui() +dpg.show_viewport() +dpg.start_dearpygui() +dpg.destroy_context() +``` + +## Dashboard Example + +```python +with dpg.window(label="Engine"): + with dpgg.gauge_panel(label="Powertrain", width=-1, height=260): + with dpgg.gauge_grid(columns=3): + dpgg.analog_gauge(tag="rpm", label="RPM", max_value=8000, redline_start=6500) + dpgg.bar_gauge(tag="boost", label="Boost", max_value=30, unit="psi") + dpgg.status_light(tag="ecu", label="ECU", state=True) +``` + +## GUI-Thread Contract + +Dear PyGui item operations must happen on the GUI thread. Gauge creation and runtime updates such as +`set_value`, `update_gauge`, `set_thresholds`, and `delete_gauge` are GUI-thread functions. Worker +threads may produce data, but applications should marshal the latest values to a Dear PyGui frame +callback before calling dpg-gauges APIs. + +## Animation And Smoothing + +Animation changes the displayed value over a short duration without changing the raw target value. +Smoothing improves readability during high-rate updates. + +```python +dpgg.digital_gauge( + tag="speed", + animation=dpgg.AnimationConfig(enabled=True, duration=0.15), + smoothing=dpgg.SmoothingConfig(enabled=True, alpha=0.25, max_step=5), +) +``` + +Disable animation with `animation=False` for immediate response. + +## Thresholds And Markers + +Thresholds and markers are visual only; they do not trigger callbacks or alarms. + +```python +dpgg.bar_gauge( + tag="coolant", + min_value=40, + max_value=120, + thresholds=[dpgg.ThresholdBand(100, 120, (255, 45, 45, 150))], + markers=[dpgg.GaugeMarker(110, (255, 255, 255, 255), thickness=3)], +) +``` + +Implemented gauge types: analog, digital, bar, level, status light, segmented, compass, +thermometer, battery, and multi-value. diff --git a/codex/AGENTS.md b/codex/AGENTS.md index 304708e..07f3eea 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -2,7 +2,7 @@ ## Current status -Step 6 completed and verified with automated checks. Step 6 commit is pending. +Step 7 completed and verified with automated checks. Step 7 commit and beta tag are pending. ## Completed steps @@ -30,6 +30,16 @@ Step 1 - Public API contract and pure core: Step 7 - Documentation, hardening, and beta release. +Implemented during Step 7: + +- Rewrote `README.md` with install, minimal examples, dashboard usage, GUI-thread expectations, animation/smoothing, and thresholds/markers. +- Added `docs/GETTING_STARTED.md`, `docs/EXAMPLES.md`, and `docs/API_REFERENCE.md`. +- Added docstrings for all public exported callables. +- Reviewed public API exports and added `__version__`. +- Added Step 7 hardening tests for unknown gauges, duplicate tags, invalid ranges, invalid thresholds, invalid markers, non-finite values, deleted updates, hidden sizing, and animation disabled/enabled behavior. +- Bumped package version to `0.1.0b1`. +- Verified a local editable install from a temporary project. + Step 6 - High-rate updates, animation, and stress examples: Implemented during Step 6: @@ -103,7 +113,7 @@ Implemented during Step 2: ## Known issues -Manual visual/stress example checks for Steps 3, 4, 5, and 6 were not run in this headless session. +Manual visual/stress example checks for Steps 3, 4, 5, 6, and 7 were not run in this headless session. ## Commands used @@ -130,6 +140,16 @@ Manual visual/stress example checks for Steps 3, 4, 5, and 6 were not run in thi - Ran `uv run ruff format .`. - Ran `uv run ruff format --check .`. - Ran `uv run pyright`. +- Committed Step 6 with `git commit -m "step 6: add animation and high rate update stress tests"` before starting Step 7. +- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 7 changes. +- Implemented Step 7 README/docs, public callable docstrings, hardening tests, version bump, and README/API status updates. +- Ran `uv run python - <<'PY' ...` to verify all exported callables have docstrings. +- Ran `uv sync`. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. +- Created `/tmp/opencode/dpg-gauges-editable-check`, ran `uv init --bare --name dpg-gauges-editable-check`, `uv add --editable /home/hector/projects/dpg-gauges`, and `uv run python -c "import dpg_gauges as dpgg; print(dpgg.__name__)"`. - Committed Step 5 with `git commit -m "step 5: complete first gauge catalogue"` before starting Step 6. - Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 6 changes. - Implemented Step 6 animation/smoothing scheduling refinements, high-rate examples, README status update, diagnostics fields, and tests. @@ -162,4 +182,4 @@ Manual visual/stress example checks for Steps 3, 4, 5, and 6 were not run in thi ## Next action -Commit Step 6, then implement Step 7. +Commit Step 7 and tag `v0.1.0b1` if requested. diff --git a/docs/.gitkeep b/docs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..b4181a6 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,41 @@ +# API Reference + +## Configuration + +- `configure(**config)`: set package-wide defaults for later gauges. + +## Gauge Creation + +Creation functions are context managers and accept common options such as `tag`, `label`, `value`, +`min_value`, `max_value`, `unit`, `width`, `height`, `show`, `tooltip`, `callback`, `theme`, +`style`, `animation`, `smoothing`, `thresholds`, and `markers`. + +- `analog_gauge(**config)` +- `digital_gauge(**config)` +- `bar_gauge(**config)` +- `level_gauge(**config)` +- `status_light(**config)` +- `segmented_gauge(**config)` +- `compass_gauge(**config)` +- `thermometer_gauge(**config)` +- `battery_gauge(**config)` +- `multi_value_gauge(**config)` + +## Layout Helpers + +- `gauge_panel(**config)`: child-window panel for grouped gauges. +- `gauge_grid(**config)`: lightweight horizontal gauge group. + +## Runtime API + +- `set_value(tag, value)`: set the latest target value. +- `get_value(tag)`: return the latest raw target value. +- `update_gauge(tag, **config)`: update value and/or configuration. +- `configure_gauge(tag, **config)`: update configuration. +- `set_thresholds(tag, thresholds)`: replace visual threshold bands. +- `set_markers(tag, markers)`: replace visual markers. +- `set_show(tag, show)`: show or hide a gauge. +- `delete_gauge(tag)`: remove a gauge. +- `get_gauge_debug_state(tag)`: return internal debug state. + +Runtime functions are GUI-thread functions unless explicitly documented otherwise. diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md new file mode 100644 index 0000000..d959d5b --- /dev/null +++ b/docs/EXAMPLES.md @@ -0,0 +1,23 @@ +# Examples + +Run examples with `uv run python examples/.py`. + +- `basic_analog.py` +- `basic_digital.py` +- `basic_bar.py` +- `basic_level.py` +- `basic_status.py` +- `basic_segmented.py` +- `basic_compass.py` +- `basic_thermometer.py` +- `basic_battery.py` +- `basic_multi_value.py` +- `thresholds_markers.py` +- `animations.py` +- `dashboard.py` +- `sizing.py` +- `live_high_rate.py` +- `live_worst_case.py` + +The live examples use a producer thread for data generation and a Dear PyGui frame callback for all +gauge updates. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..353a610 --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,19 @@ +# Getting Started + +Install with `uv add dpg-gauges`, then import as `import dpg_gauges as dpgg`. + +Create gauges inside a Dear PyGui context: + +```python +import dearpygui.dearpygui as dpg +import dpg_gauges as dpgg + +dpg.create_context() +with dpg.window(label="Dashboard"): + dpgg.digital_gauge(tag="speed", label="Speed", value=72, unit="km/h") + +dpgg.set_value("speed", 88) +``` + +All widget creation and runtime updates are Dear PyGui GUI-thread operations. If telemetry is +produced in a worker thread, store the latest value and apply it from a GUI frame callback. diff --git a/pyproject.toml b/pyproject.toml index 6253b55..403bf97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "dpg-gauges" -version = "0.1.0" -description = "Add your description here" +version = "0.1.0b1" +description = "Dear PyGui gauge widgets for dashboards and telemetry displays" readme = "README.md" authors = [ { name = "Hector van der Aa", email = "hector@h3cx.dev" } diff --git a/src/dpg_gauges/__init__.py b/src/dpg_gauges/__init__.py index 667dea2..16443d0 100644 --- a/src/dpg_gauges/__init__.py +++ b/src/dpg_gauges/__init__.py @@ -35,6 +35,8 @@ from .types import ( ThresholdBand, ) +__version__ = "0.1.0b1" + __all__ = [ "configure", "GaugeTheme", @@ -67,8 +69,10 @@ __all__ = [ "set_show", "delete_gauge", "get_gauge_debug_state", + "__version__", ] def main() -> None: + """Print a short message for the package console script.""" print("dpg-gauges is a library package; import dpg_gauges to use it.") diff --git a/src/dpg_gauges/api.py b/src/dpg_gauges/api.py index ac513be..81c864c 100644 --- a/src/dpg_gauges/api.py +++ b/src/dpg_gauges/api.py @@ -20,6 +20,7 @@ from .types import GaugeMarker, GaugeValue, Tag, ThresholdBand def configure(**config: Any) -> None: + """Configure package-wide defaults for subsequently created gauges.""" configure_gauges(**config) @@ -38,34 +39,42 @@ gauge_grid = _widget.gauge_grid def set_value(tag: Tag, value: GaugeValue) -> None: + """Set a gauge target value from the Dear PyGui GUI thread.""" set_runtime_value(tag, value) def get_value(tag: Tag) -> GaugeValue: + """Return the latest raw target value assigned to a gauge.""" return get_runtime_value(tag) def update_gauge(tag: Tag, **config: Any) -> None: + """Update a gauge value and/or configuration fields.""" update_runtime_gauge(tag, **config) def configure_gauge(tag: Tag, **config: Any) -> None: + """Update configuration fields for an existing gauge.""" update_runtime_gauge(tag, **config) def set_thresholds(tag: Tag, thresholds: list[ThresholdBand] | tuple[ThresholdBand, ...]) -> None: + """Replace the visual threshold bands for an existing gauge.""" set_runtime_thresholds(tag, thresholds) def set_markers(tag: Tag, markers: list[GaugeMarker] | tuple[GaugeMarker, ...]) -> None: + """Replace the visual markers for an existing gauge.""" set_runtime_markers(tag, markers) def set_show(tag: Tag, show: bool) -> None: + """Show or hide an existing gauge.""" set_runtime_show(tag, show) def delete_gauge(tag: Tag) -> None: + """Delete an existing gauge and its Dear PyGui shell when attached.""" delete_runtime_gauge(tag) diff --git a/src/dpg_gauges/diagnostics.py b/src/dpg_gauges/diagnostics.py index d3a7d15..fc8db6d 100644 --- a/src/dpg_gauges/diagnostics.py +++ b/src/dpg_gauges/diagnostics.py @@ -9,6 +9,7 @@ from .types import Tag def get_gauge_debug_state(tag: Tag) -> dict[str, Any]: + """Return renderer and state diagnostics for an existing gauge.""" state = get_gauge_state(tag) debug = { "tag": state.tag, diff --git a/src/dpg_gauges/widget.py b/src/dpg_gauges/widget.py index 789d41f..24e54f3 100644 --- a/src/dpg_gauges/widget.py +++ b/src/dpg_gauges/widget.py @@ -139,6 +139,7 @@ def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[Confi def gauge_panel(**config: Any) -> LayoutContext: + """Create a lightweight child-window container for related gauges.""" if not _dpg_context_active: return LayoutContext(None) tag = config.pop("tag", 0) @@ -160,6 +161,7 @@ def gauge_panel(**config: Any) -> LayoutContext: def gauge_grid(**config: Any) -> LayoutContext: + """Create a lightweight horizontal gauge layout container.""" if not _dpg_context_active: return LayoutContext(None) columns = max(int(config.pop("columns", 1)), 1) @@ -175,42 +177,52 @@ def gauge_grid(**config: Any) -> LayoutContext: def analog_gauge(**config: Any) -> GaugeContext[AnalogGaugeConfig]: + """Create an analog dial gauge with ticks, needle, thresholds, and markers.""" return create_gauge_context("analog", AnalogGaugeConfig(**config)) def digital_gauge(**config: Any) -> GaugeContext[DigitalGaugeConfig]: + """Create a large numeric readout gauge.""" return create_gauge_context("digital", DigitalGaugeConfig(**config)) def bar_gauge(**config: Any) -> GaugeContext[BarGaugeConfig]: + """Create a horizontal or vertical fill bar gauge.""" return create_gauge_context("bar", BarGaugeConfig(**config)) def level_gauge(**config: Any) -> GaugeContext[LevelGaugeConfig]: + """Create a tank or level-style vertical gauge.""" return create_gauge_context("level", LevelGaugeConfig(**config)) def status_light(**config: Any) -> GaugeContext[StatusLightConfig]: + """Create an LED-style status indicator gauge.""" return create_gauge_context("status_light", StatusLightConfig(**config)) def segmented_gauge(**config: Any) -> GaugeContext[SegmentedGaugeConfig]: + """Create a segmented strip gauge for shift-light or cell-style displays.""" return create_gauge_context("segmented", SegmentedGaugeConfig(**config)) def compass_gauge(**config: Any) -> GaugeContext[CompassGaugeConfig]: + """Create a compass gauge that wraps numeric headings to 0-360 degrees.""" return create_gauge_context("compass", CompassGaugeConfig(**config)) def thermometer_gauge(**config: Any) -> GaugeContext[ThermometerGaugeConfig]: + """Create a thermometer-style temperature gauge.""" return create_gauge_context("thermometer", ThermometerGaugeConfig(**config)) def battery_gauge(**config: Any) -> GaugeContext[BatteryGaugeConfig]: + """Create a battery-style percentage or value gauge.""" return create_gauge_context("battery", BatteryGaugeConfig(**config)) def multi_value_gauge(**config: Any) -> GaugeContext[MultiValueGaugeConfig]: + """Create a compact grouped key/value readout gauge.""" return create_gauge_context("multi_value", MultiValueGaugeConfig(**config)) diff --git a/tests/test_step7_hardening.py b/tests/test_step7_hardening.py new file mode 100644 index 0000000..deadddc --- /dev/null +++ b/tests/test_step7_hardening.py @@ -0,0 +1,93 @@ +# pyright: reportGeneralTypeIssues=false + +from collections.abc import Generator + +import dearpygui.dearpygui as dpg +import pytest + +import dpg_gauges as dpgg +from dpg_gauges.exceptions import GaugeConfigError, GaugeNotFoundError +from dpg_gauges.gauges import DigitalGaugeConfig +from dpg_gauges.state import clear_registry, get_gauge_state, register_gauge +from dpg_gauges.types import AnimationConfig + + +@pytest.fixture(autouse=True) +def clean_registry() -> Generator[None]: + clear_registry() + dpgg.configure() + yield + clear_registry() + + +@pytest.fixture +def dpg_context() -> Generator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +def test_public_exports_and_version() -> None: + assert dpgg.__version__ == "0.1.0b1" + for name in dpgg.__all__: + assert hasattr(dpgg, name) + + +def test_unknown_duplicate_and_deleted_gauge_errors() -> None: + with pytest.raises(GaugeNotFoundError): + dpgg.set_value("missing", 1) + + dpgg.digital_gauge(tag="speed") + with pytest.raises(GaugeConfigError): + register_gauge("digital", DigitalGaugeConfig(tag="speed")) + + dpgg.delete_gauge("speed") + with pytest.raises(GaugeNotFoundError): + dpgg.update_gauge("speed", value=2) + + +def test_invalid_range_threshold_marker_and_non_finite_value() -> None: + with pytest.raises(GaugeConfigError): + dpgg.digital_gauge(tag="bad", min_value=10, max_value=0) + + dpgg.bar_gauge(tag="bar", min_value=0, max_value=100) + with pytest.raises(GaugeConfigError): + dpgg.set_thresholds("bar", [dpgg.ThresholdBand(float("nan"), 10, (255, 0, 0))]) + with pytest.raises(GaugeConfigError): + dpgg.set_markers("bar", [dpgg.GaugeMarker(float("nan"), (255, 255, 255))]) + + dpgg.set_value("bar", float("inf")) + state = get_gauge_state("bar") + assert state.invalid_value is True + assert state.clamped_value is None + + +def test_hidden_layout_sizing_survives_show_later(dpg_context: None) -> None: + with dpg.window(label="Hidden"): + dpgg.digital_gauge(tag="hidden", value=1, width=180, height=80, show=False) + + state = get_gauge_state("hidden") + assert state.last_nonzero_width == 180 + assert state.last_nonzero_height == 80 + + dpgg.set_show("hidden", True) + assert state.config.show is True + assert state.last_nonzero_width == 180 + + +def test_animation_disabled_and_enabled_modes() -> None: + dpgg.digital_gauge(tag="instant", value=0, animation=False) + dpgg.set_value("instant", 100) + instant = get_gauge_state("instant") + assert instant.animation_state.active is False + assert instant.displayed_value == 100 + + dpgg.digital_gauge( + tag="animated", value=0, animation=AnimationConfig(enabled=True, duration=1.0) + ) + dpgg.set_value("animated", 100) + animated = get_gauge_state("animated") + assert animated.animation_state.active is True + assert animated.animation_state.target_value == 100 diff --git a/uv.lock b/uv.lock index 5ce68d0..701161d 100644 --- a/uv.lock +++ b/uv.lock @@ -36,7 +36,7 @@ wheels = [ [[package]] name = "dpg-gauges" -version = "0.1.0" +version = "0.1.0b1" source = { editable = "." } dependencies = [ { name = "dearpygui" },