step 2: add gauge state sizing and animation model
This commit is contained in:
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
|
||||
9
README.md
Normal file
9
README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# dpg-gauges
|
||||
|
||||
Dear PyGui gauge widgets for dashboards and telemetry displays.
|
||||
|
||||
Current implementation status: Step 2 logical state is implemented. Gauge creation functions build
|
||||
and register pure gauge state without requiring a Dear PyGui context; final widget shells and drawing
|
||||
arrive in later steps.
|
||||
|
||||
Runtime value updates are intended for the Dear PyGui GUI thread once rendering is implemented.
|
||||
@@ -2,15 +2,44 @@
|
||||
|
||||
## Current status
|
||||
|
||||
Project instruction set initialized.
|
||||
Step 2 completed and verified. Ready to start Step 3.
|
||||
|
||||
## Completed steps
|
||||
|
||||
None yet.
|
||||
Initial setup before Step 1:
|
||||
|
||||
- `uv` package metadata exists.
|
||||
- Runtime dependency `dearpygui` is installed.
|
||||
- Dev dependencies `pytest`, `ruff`, and `pyright` are installed.
|
||||
- Required package skeleton exists under `src/dpg_gauges/`.
|
||||
- `examples/`, `tests/`, and `docs/` directories exist.
|
||||
- `pyproject.toml` is configured for Python 3.11+, Ruff, and Pyright.
|
||||
|
||||
Step 1 - Public API contract and pure core:
|
||||
|
||||
- Public API exports are defined in `src/dpg_gauges/__init__.py`.
|
||||
- Public exceptions are defined in `src/dpg_gauges/exceptions.py`.
|
||||
- Shared public dataclasses and type aliases are defined in `src/dpg_gauges/types.py`.
|
||||
- Pure gauge configuration dataclasses are defined in `src/dpg_gauges/gauges.py`.
|
||||
- Pure scale helpers are defined in `src/dpg_gauges/scales.py`.
|
||||
- Lightweight theme/style presets are defined in `src/dpg_gauges/themes.py`.
|
||||
- GUI-dependent public functions are import-safe stubs for Step 1.
|
||||
- Step 1 tests cover public imports, dataclass construction, clamp/normalize math, tick generation, threshold validation, formatting, and missing-gauge stubs.
|
||||
|
||||
## Current step
|
||||
|
||||
Step 1 - Public API contract and pure core.
|
||||
Step 3 - Widget shell and renderer foundation.
|
||||
|
||||
Step 2 - State, sizing, animation, and smoothing model:
|
||||
|
||||
Implemented during Step 2:
|
||||
|
||||
- Added `DpgGaugesConfig`, `configure(...)` wiring, `GaugeState`, `DirtyFlags`, and the global gauge registry.
|
||||
- Runtime API functions now mutate logical state for values, config updates, thresholds, markers, visibility, and deletion without Dear PyGui calls.
|
||||
- Added pure sizing helpers for requested/measured size, last non-zero size preservation, square rects, and stretch rects.
|
||||
- Added pure animation interpolation helpers and smoothing progression helpers.
|
||||
- Added debug state reporting for registered gauges.
|
||||
- Added Step 2 tests for registry behaviour, clamped/displayed separation, dirty flags, sizing, animation, smoothing, and invalid values.
|
||||
|
||||
## Design decisions
|
||||
|
||||
@@ -25,6 +54,7 @@ Step 1 - Public API contract and pure core.
|
||||
- Gauges are display-only for the first release.
|
||||
- Thresholds and redline zones are visual only; they do not trigger events.
|
||||
- The public API should feel like normal Dear PyGui context-manager usage.
|
||||
- Each completed build step must be checked, documented in this file, and committed before starting the next step.
|
||||
|
||||
## Known issues
|
||||
|
||||
@@ -35,7 +65,26 @@ None yet.
|
||||
- Read `codex-old/README.md`, `codex-old/FEATURES.md`, `codex-old/ARCHITECTURE.md`, `codex-old/STEPS.md`, and `codex-old/AGENTS.md`.
|
||||
- Inspected `/home/hector/projects/dpg-map` package, examples, tests, docs, and pyproject structure.
|
||||
- Created the initial `codex/` instruction documents for `dpg-gauges`.
|
||||
- Read `codex/README.md`, `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md`.
|
||||
- Completed missing pre-Step-1 skeleton files and directories.
|
||||
- Ran `uv run python -c "import dpg_gauges; print(dpg_gauges.__name__)"`.
|
||||
- Ran `uv run ruff check .`.
|
||||
- Ran `uv run ruff format .`.
|
||||
- Read all files under `codex/`.
|
||||
- Implemented Step 1 public API exports and pure core modules.
|
||||
- Added `tests/test_step1_core.py`.
|
||||
- Ran `uv run pytest`.
|
||||
- Ran `uv run ruff check .`.
|
||||
- Ran `uv run ruff format --check .`.
|
||||
- Ran `uv run pyright`.
|
||||
- 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.
|
||||
|
||||
## Next action
|
||||
|
||||
Implement Step 1.
|
||||
Implement Step 3.
|
||||
|
||||
@@ -8,12 +8,13 @@ There is no Step 0. Initial setup is listed separately, then implementation star
|
||||
2. Always read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, and `codex/AGENTS.md` before making code changes.
|
||||
3. Keep `codex/AGENTS.md` as a rolling log of what has been done, what is broken, and what comes next.
|
||||
4. Update `README.md` and `docs/` whenever public behaviour or examples change.
|
||||
5. After every step, update `codex/AGENTS.md`, run relevant checks, and commit if working in git.
|
||||
5. After every step, update `codex/AGENTS.md`, run relevant checks, and commit the completed step when working in git.
|
||||
6. Do not casually change public API once introduced.
|
||||
7. Dear PyGui calls must happen on the GUI thread only.
|
||||
8. Runtime gauge updates are GUI-thread-only unless explicitly documented otherwise.
|
||||
9. Prefer stable, boring implementation over clever drawing tricks.
|
||||
10. Keep each step shippable and tested before moving to the next.
|
||||
11. Do not start the next step while the previous completed step is still uncommitted.
|
||||
|
||||
## Initial setup instructions
|
||||
|
||||
|
||||
0
docs/.gitkeep
Normal file
0
docs/.gitkeep
Normal file
0
examples/.gitkeep
Normal file
0
examples/.gitkeep
Normal file
36
pyproject.toml
Normal file
36
pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[project]
|
||||
name = "dpg-gauges"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
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"
|
||||
74
src/dpg_gauges/__init__.py
Normal file
74
src/dpg_gauges/__init__.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
__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",
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
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
|
||||
129
src/dpg_gauges/api.py
Normal file
129
src/dpg_gauges/api.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Public API wrappers for logical gauge state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
from typing import Any, cast
|
||||
|
||||
from .gauges import (
|
||||
AnalogGaugeConfig,
|
||||
BarGaugeConfig,
|
||||
BatteryGaugeConfig,
|
||||
CompassGaugeConfig,
|
||||
DigitalGaugeConfig,
|
||||
GaugeConfig,
|
||||
LevelGaugeConfig,
|
||||
MultiValueGaugeConfig,
|
||||
SegmentedGaugeConfig,
|
||||
StatusLightConfig,
|
||||
ThermometerGaugeConfig,
|
||||
)
|
||||
from .state import (
|
||||
configure_gauges,
|
||||
delete_runtime_gauge,
|
||||
get_runtime_value,
|
||||
register_gauge,
|
||||
set_runtime_markers,
|
||||
set_runtime_show,
|
||||
set_runtime_thresholds,
|
||||
set_runtime_value,
|
||||
update_runtime_gauge,
|
||||
)
|
||||
from .types import GaugeMarker, GaugeValue, Tag, ThresholdBand
|
||||
|
||||
|
||||
def configure(**config: Any) -> None:
|
||||
configure_gauges(**config)
|
||||
|
||||
|
||||
def analog_gauge(**config: Any) -> nullcontext[AnalogGaugeConfig]:
|
||||
state = register_gauge("analog", AnalogGaugeConfig(**config))
|
||||
return nullcontext(cast(AnalogGaugeConfig, state.config))
|
||||
|
||||
|
||||
def digital_gauge(**config: Any) -> nullcontext[DigitalGaugeConfig]:
|
||||
state = register_gauge("digital", DigitalGaugeConfig(**config))
|
||||
return nullcontext(cast(DigitalGaugeConfig, state.config))
|
||||
|
||||
|
||||
def bar_gauge(**config: Any) -> nullcontext[BarGaugeConfig]:
|
||||
state = register_gauge("bar", BarGaugeConfig(**config))
|
||||
return nullcontext(cast(BarGaugeConfig, state.config))
|
||||
|
||||
|
||||
def level_gauge(**config: Any) -> nullcontext[LevelGaugeConfig]:
|
||||
state = register_gauge("level", LevelGaugeConfig(**config))
|
||||
return nullcontext(cast(LevelGaugeConfig, state.config))
|
||||
|
||||
|
||||
def status_light(**config: Any) -> nullcontext[StatusLightConfig]:
|
||||
state = register_gauge("status_light", StatusLightConfig(**config))
|
||||
return nullcontext(cast(StatusLightConfig, state.config))
|
||||
|
||||
|
||||
def segmented_gauge(**config: Any) -> nullcontext[SegmentedGaugeConfig]:
|
||||
state = register_gauge("segmented", SegmentedGaugeConfig(**config))
|
||||
return nullcontext(cast(SegmentedGaugeConfig, state.config))
|
||||
|
||||
|
||||
def compass_gauge(**config: Any) -> nullcontext[CompassGaugeConfig]:
|
||||
state = register_gauge("compass", CompassGaugeConfig(**config))
|
||||
return nullcontext(cast(CompassGaugeConfig, state.config))
|
||||
|
||||
|
||||
def thermometer_gauge(**config: Any) -> nullcontext[ThermometerGaugeConfig]:
|
||||
state = register_gauge("thermometer", ThermometerGaugeConfig(**config))
|
||||
return nullcontext(cast(ThermometerGaugeConfig, state.config))
|
||||
|
||||
|
||||
def battery_gauge(**config: Any) -> nullcontext[BatteryGaugeConfig]:
|
||||
state = register_gauge("battery", BatteryGaugeConfig(**config))
|
||||
return nullcontext(cast(BatteryGaugeConfig, state.config))
|
||||
|
||||
|
||||
def multi_value_gauge(**config: Any) -> nullcontext[MultiValueGaugeConfig]:
|
||||
state = register_gauge("multi_value", MultiValueGaugeConfig(**config))
|
||||
return nullcontext(cast(MultiValueGaugeConfig, state.config))
|
||||
|
||||
|
||||
def gauge_panel(**_config: Any) -> nullcontext[None]:
|
||||
return nullcontext(None)
|
||||
|
||||
|
||||
def gauge_grid(**_config: Any) -> nullcontext[None]:
|
||||
return nullcontext(None)
|
||||
|
||||
|
||||
def set_value(tag: Tag, value: GaugeValue) -> None:
|
||||
set_runtime_value(tag, value)
|
||||
|
||||
|
||||
def get_value(tag: Tag) -> GaugeValue:
|
||||
return get_runtime_value(tag)
|
||||
|
||||
|
||||
def update_gauge(tag: Tag, **config: Any) -> None:
|
||||
update_runtime_gauge(tag, **config)
|
||||
|
||||
|
||||
def configure_gauge(tag: Tag, **config: Any) -> None:
|
||||
update_runtime_gauge(tag, **config)
|
||||
|
||||
|
||||
def set_thresholds(tag: Tag, thresholds: list[ThresholdBand] | tuple[ThresholdBand, ...]) -> None:
|
||||
set_runtime_thresholds(tag, thresholds)
|
||||
|
||||
|
||||
def set_markers(tag: Tag, markers: list[GaugeMarker] | tuple[GaugeMarker, ...]) -> None:
|
||||
set_runtime_markers(tag, markers)
|
||||
|
||||
|
||||
def set_show(tag: Tag, show: bool) -> None:
|
||||
set_runtime_show(tag, show)
|
||||
|
||||
|
||||
def delete_gauge(tag: Tag) -> None:
|
||||
delete_runtime_gauge(tag)
|
||||
|
||||
|
||||
__all_configs__ = (GaugeConfig,)
|
||||
28
src/dpg_gauges/diagnostics.py
Normal file
28
src/dpg_gauges/diagnostics.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""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]:
|
||||
state = get_gauge_state(tag)
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
0
src/dpg_gauges/draw_layers.py
Normal file
0
src/dpg_gauges/draw_layers.py
Normal file
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."""
|
||||
150
src/dpg_gauges/gauges.py
Normal file
150
src/dpg_gauges/gauges.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Pure gauge configuration dataclasses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Literal
|
||||
|
||||
from .scales import validate_markers, validate_range, validate_thresholds
|
||||
from .themes import GaugeStyle, GaugeTheme, get_theme, merge_style
|
||||
from .types import (
|
||||
AnimationConfig,
|
||||
Callback,
|
||||
GaugeMarker,
|
||||
GaugeValue,
|
||||
SmoothingConfig,
|
||||
StatusState,
|
||||
Tag,
|
||||
ThresholdBand,
|
||||
ValueFormatter,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_animation(value: AnimationConfig | bool | None) -> AnimationConfig | None:
|
||||
if isinstance(value, bool):
|
||||
return AnimationConfig(enabled=value)
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_smoothing(value: SmoothingConfig | bool | None) -> SmoothingConfig | None:
|
||||
if isinstance(value, bool):
|
||||
return SmoothingConfig(enabled=value)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GaugeConfig:
|
||||
tag: Tag | None = None
|
||||
label: str | None = None
|
||||
value: GaugeValue = None
|
||||
min_value: float = 0.0
|
||||
max_value: float = 100.0
|
||||
unit: str = ""
|
||||
precision: int = 0
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
autosize_x: bool = False
|
||||
autosize_y: bool = False
|
||||
show: bool = True
|
||||
show_label: bool = True
|
||||
show_value: bool = True
|
||||
show_unit: bool = True
|
||||
tooltip: str | None = None
|
||||
callback: Callback | None = None
|
||||
user_data: Any = None
|
||||
theme: str | GaugeTheme | None = None
|
||||
style: GaugeStyle | None = None
|
||||
animation: AnimationConfig | bool | None = None
|
||||
smoothing: SmoothingConfig | bool | None = None
|
||||
clamp: bool = True
|
||||
format_value: ValueFormatter | None = None
|
||||
thresholds: Sequence[ThresholdBand] = ()
|
||||
markers: Sequence[GaugeMarker] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_range(self.min_value, self.max_value)
|
||||
validate_thresholds(self.thresholds, self.min_value, self.max_value)
|
||||
validate_markers(self.markers, self.min_value, self.max_value)
|
||||
if self.precision < 0:
|
||||
msg = "precision must be non-negative"
|
||||
raise ValueError(msg)
|
||||
object.__setattr__(self, "theme", get_theme(self.theme))
|
||||
object.__setattr__(self, "style", self.style or get_theme(self.theme).style)
|
||||
object.__setattr__(self, "animation", _normalize_animation(self.animation))
|
||||
object.__setattr__(self, "smoothing", _normalize_smoothing(self.smoothing))
|
||||
object.__setattr__(self, "thresholds", tuple(self.thresholds))
|
||||
object.__setattr__(self, "markers", tuple(self.markers))
|
||||
|
||||
def with_style_overrides(self, **overrides: Any) -> GaugeConfig:
|
||||
return replace(self, style=merge_style(self.style, **overrides))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnalogGaugeConfig(GaugeConfig):
|
||||
start_angle: float = 225.0
|
||||
end_angle: float = -45.0
|
||||
major_ticks: int = 8
|
||||
minor_ticks: int = 4
|
||||
show_tick_labels: bool = True
|
||||
needle_length: float = 0.82
|
||||
needle_width: float = 3.0
|
||||
center_cap_radius: float = 7.0
|
||||
arc_width: float = 8.0
|
||||
maintain_aspect: bool = True
|
||||
redline_start: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DigitalGaugeConfig(GaugeConfig):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BarGaugeConfig(GaugeConfig):
|
||||
orientation: Literal["horizontal", "vertical"] = "horizontal"
|
||||
fill_mode: Literal["clamp", "centered"] = "clamp"
|
||||
corner_radius: float = 4.0
|
||||
show_scale: bool = True
|
||||
stretch: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LevelGaugeConfig(BarGaugeConfig):
|
||||
orientation: Literal["horizontal", "vertical"] = "vertical"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatusLightConfig(GaugeConfig):
|
||||
state: str | bool | int = False
|
||||
states: Mapping[Any, StatusState] | None = None
|
||||
blink: bool = False
|
||||
pulse: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SegmentedGaugeConfig(BarGaugeConfig):
|
||||
segments: int = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompassGaugeConfig(AnalogGaugeConfig):
|
||||
min_value: float = 0.0
|
||||
max_value: float = 360.0
|
||||
start_angle: float = 90.0
|
||||
end_angle: float = -270.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThermometerGaugeConfig(LevelGaugeConfig):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BatteryGaugeConfig(LevelGaugeConfig):
|
||||
charging: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MultiValueGaugeConfig(GaugeConfig):
|
||||
values: Mapping[str, GaugeValue] | None = None
|
||||
0
src/dpg_gauges/renderer.py
Normal file
0
src/dpg_gauges/renderer.py
Normal file
104
src/dpg_gauges/scales.py
Normal file
104
src/dpg_gauges/scales.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Pure scale, formatting, and value mapping helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
|
||||
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 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 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
|
||||
305
src/dpg_gauges/state.py
Normal file
305
src/dpg_gauges/state.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""Global configuration, gauge registry, and runtime state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
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 GaugeConfig
|
||||
from .scales import clamp_value, 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 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 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 = 0.0) -> None:
|
||||
state = get_gauge_state(tag)
|
||||
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:
|
||||
state.displayed_value = clamped
|
||||
state.smoothing_state.value = clamped
|
||||
state.dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT
|
||||
|
||||
|
||||
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 ("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
|
||||
|
||||
|
||||
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:
|
||||
get_gauge_state(tag)
|
||||
del _registry[tag]
|
||||
|
||||
|
||||
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]
|
||||
0
src/dpg_gauges/widget.py
Normal file
0
src/dpg_gauges/widget.py
Normal file
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"]
|
||||
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 = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "dearpygui" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "dearpygui", specifier = ">=2.3.1" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pyright", specifier = ">=1.1.411" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "ruff", specifier = ">=0.15.20" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyright"
|
||||
version = "1.1.411"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodeenv" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user