step 2: add gauge state sizing and animation model

This commit is contained in:
2026-07-02 13:54:58 +02:00
parent 44e4cbd246
commit 34fd60c34e
27 changed files with 1732 additions and 5 deletions

5
.gitignore vendored Normal file
View File

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

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.11

9
README.md Normal file
View 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.

View File

@@ -2,15 +2,44 @@
## Current status ## Current status
Project instruction set initialized. Step 2 completed and verified. Ready to start Step 3.
## Completed steps ## Completed steps
None yet. Initial setup before Step 1:
- `uv` package metadata exists.
- Runtime dependency `dearpygui` is installed.
- Dev dependencies `pytest`, `ruff`, and `pyright` are installed.
- Required package skeleton exists under `src/dpg_gauges/`.
- `examples/`, `tests/`, and `docs/` directories exist.
- `pyproject.toml` is configured for Python 3.11+, Ruff, and Pyright.
Step 1 - Public API contract and pure core:
- Public API exports are defined in `src/dpg_gauges/__init__.py`.
- Public exceptions are defined in `src/dpg_gauges/exceptions.py`.
- Shared public dataclasses and type aliases are defined in `src/dpg_gauges/types.py`.
- Pure gauge configuration dataclasses are defined in `src/dpg_gauges/gauges.py`.
- Pure scale helpers are defined in `src/dpg_gauges/scales.py`.
- Lightweight theme/style presets are defined in `src/dpg_gauges/themes.py`.
- GUI-dependent public functions are import-safe stubs for Step 1.
- Step 1 tests cover public imports, dataclass construction, clamp/normalize math, tick generation, threshold validation, formatting, and missing-gauge stubs.
## Current step ## Current step
Step 1 - Public API contract and pure core. Step 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 ## Design decisions
@@ -25,6 +54,7 @@ Step 1 - Public API contract and pure core.
- Gauges are display-only for the first release. - Gauges are display-only for the first release.
- Thresholds and redline zones are visual only; they do not trigger events. - Thresholds and redline zones are visual only; they do not trigger events.
- The public API should feel like normal Dear PyGui context-manager usage. - The public API should feel like normal Dear PyGui context-manager usage.
- Each completed build step must be checked, documented in this file, and committed before starting the next step.
## Known issues ## Known issues
@@ -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`. - Read `codex-old/README.md`, `codex-old/FEATURES.md`, `codex-old/ARCHITECTURE.md`, `codex-old/STEPS.md`, and `codex-old/AGENTS.md`.
- Inspected `/home/hector/projects/dpg-map` package, examples, tests, docs, and pyproject structure. - Inspected `/home/hector/projects/dpg-map` package, examples, tests, docs, and pyproject structure.
- Created the initial `codex/` instruction documents for `dpg-gauges`. - Created the initial `codex/` instruction documents for `dpg-gauges`.
- Read `codex/README.md`, `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md`.
- Completed missing pre-Step-1 skeleton files and directories.
- Ran `uv run python -c "import dpg_gauges; print(dpg_gauges.__name__)"`.
- Ran `uv run ruff check .`.
- Ran `uv run ruff format .`.
- Read all files under `codex/`.
- Implemented Step 1 public API exports and pure core modules.
- Added `tests/test_step1_core.py`.
- Ran `uv run pytest`.
- Ran `uv run ruff check .`.
- Ran `uv run ruff format --check .`.
- Ran `uv run pyright`.
- 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 ## Next action
Implement Step 1. Implement Step 3.

View File

@@ -8,12 +8,13 @@ There is no Step 0. Initial setup is listed separately, then implementation star
2. Always read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, and `codex/AGENTS.md` before making code changes. 2. Always read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, and `codex/AGENTS.md` before making code changes.
3. Keep `codex/AGENTS.md` as a rolling log of what has been done, what is broken, and what comes next. 3. Keep `codex/AGENTS.md` as a rolling log of what has been done, what is broken, and what comes next.
4. Update `README.md` and `docs/` whenever public behaviour or examples change. 4. Update `README.md` and `docs/` whenever public behaviour or examples change.
5. After every step, update `codex/AGENTS.md`, run relevant checks, and commit if working in git. 5. After every step, update `codex/AGENTS.md`, run relevant checks, and commit the completed step when working in git.
6. Do not casually change public API once introduced. 6. Do not casually change public API once introduced.
7. Dear PyGui calls must happen on the GUI thread only. 7. Dear PyGui calls must happen on the GUI thread only.
8. Runtime gauge updates are GUI-thread-only unless explicitly documented otherwise. 8. Runtime gauge updates are GUI-thread-only unless explicitly documented otherwise.
9. Prefer stable, boring implementation over clever drawing tricks. 9. Prefer stable, boring implementation over clever drawing tricks.
10. Keep each step shippable and tested before moving to the next. 10. Keep each step shippable and tested before moving to the next.
11. Do not start the next step while the previous completed step is still uncommitted.
## Initial setup instructions ## Initial setup instructions

0
docs/.gitkeep Normal file
View File

0
examples/.gitkeep Normal file
View File

36
pyproject.toml Normal file
View 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"

View 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.")

View File

@@ -0,0 +1,72 @@
"""Pure animation interpolation helpers."""
from __future__ import annotations
from dataclasses import dataclass
from .types import AnimationConfig
@dataclass
class AnimationState:
start_value: float | None = None
target_value: float | None = None
started_at: float = 0.0
duration: float = 0.0
active: bool = False
def easing_fraction(fraction: float, easing: str = "linear") -> float:
fraction = min(max(fraction, 0.0), 1.0)
if easing == "linear":
return fraction
if easing == "ease_in":
return fraction * fraction
if easing == "ease_out":
return 1.0 - (1.0 - fraction) * (1.0 - fraction)
if easing == "ease_in_out":
if fraction < 0.5:
return 2.0 * fraction * fraction
return 1.0 - ((-2.0 * fraction + 2.0) ** 2) / 2.0
return fraction
def interpolate(start: float, end: float, fraction: float, easing: str = "linear") -> float:
eased = easing_fraction(fraction, easing)
return start + (end - start) * eased
def start_animation(
displayed_value: float | None,
target_value: float | None,
config: AnimationConfig | None,
now: float,
) -> AnimationState:
if displayed_value is None or target_value is None:
return AnimationState(target_value, target_value, now, 0.0, False)
if (
config is None
or not config.enabled
or config.duration <= 0
or displayed_value == target_value
):
return AnimationState(target_value, target_value, now, 0.0, False)
return AnimationState(displayed_value, target_value, now, config.duration, True)
def advance_animation(
state: AnimationState, config: AnimationConfig | None, now: float
) -> tuple[float | None, bool]:
if not state.active:
return state.target_value, False
if state.start_value is None or state.target_value is None or state.duration <= 0:
state.active = False
return state.target_value, False
fraction = (now - state.started_at) / state.duration
if fraction >= 1.0:
state.active = False
return state.target_value, False
easing = config.easing if config is not None else "linear"
return interpolate(state.start_value, state.target_value, fraction, easing), True

129
src/dpg_gauges/api.py Normal file
View 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,)

View 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,
}

View File

View File

@@ -0,0 +1,25 @@
"""Public exceptions for dpg-gauges."""
class DpgGaugesError(Exception):
"""Base exception for all dpg-gauges errors."""
class GaugeNotFoundError(DpgGaugesError):
"""Raised when a gauge tag cannot be found."""
class GaugeConfigError(DpgGaugesError):
"""Raised when gauge configuration is invalid."""
class GaugeValueError(DpgGaugesError):
"""Raised when a gauge value cannot be accepted."""
class GaugeTypeError(DpgGaugesError):
"""Raised when a gauge type is invalid or unsupported."""
class ThreadingError(DpgGaugesError):
"""Raised when Dear PyGui operations are called from the wrong thread."""

150
src/dpg_gauges/gauges.py Normal file
View 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

View File

104
src/dpg_gauges/scales.py Normal file
View 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
View File

@@ -0,0 +1,64 @@
"""Pure sizing helpers for gauge layout state."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class RequestedSize:
width: int = 0
height: int = 0
autosize_x: bool = False
autosize_y: bool = False
@dataclass
class SizeState:
requested: RequestedSize = RequestedSize()
measured_width: int = 0
measured_height: int = 0
last_nonzero_width: int = 0
last_nonzero_height: int = 0
@dataclass(frozen=True)
class Rect:
x: float
y: float
width: float
height: float
def requested_size(
width: int = 0, height: int = 0, *, autosize_x: bool = False, autosize_y: bool = False
) -> RequestedSize:
return RequestedSize(width, height, autosize_x, autosize_y)
def update_measured_size(state: SizeState, width: int, height: int) -> bool:
width = max(width, 0)
height = max(height, 0)
changed = state.measured_width != width or state.measured_height != height
state.measured_width = width
state.measured_height = height
if width > 0:
state.last_nonzero_width = width
if height > 0:
state.last_nonzero_height = height
return changed
def effective_size(state: SizeState) -> tuple[int, int]:
width = state.measured_width or state.last_nonzero_width or max(state.requested.width, 0)
height = state.measured_height or state.last_nonzero_height or max(state.requested.height, 0)
return width, height
def square_gauge_rect(width: int, height: int) -> Rect:
size = float(max(min(width, height), 0))
return Rect((width - size) / 2.0, (height - size) / 2.0, size, size)
def stretch_gauge_rect(width: int, height: int) -> Rect:
return Rect(0.0, 0.0, float(max(width, 0)), float(max(height, 0)))

View File

@@ -0,0 +1,45 @@
"""Pure smoothing helpers for readable value transitions."""
from __future__ import annotations
from dataclasses import dataclass
from .types import SmoothingConfig
@dataclass
class SmoothingState:
value: float | None = None
active: bool = False
def smooth_value(
previous: float | None, target: float | None, config: SmoothingConfig | None
) -> tuple[float | None, bool]:
if target is None:
return None, False
if previous is None or config is None or not config.enabled:
return target, False
delta = target - previous
step = delta * config.alpha
if config.max_step is not None:
step = min(max(step, -config.max_step), config.max_step)
value = previous + step
active = abs(target - value) > 1e-9
return value, active
def advance_smoothing(
state: SmoothingState, target: float | None, config: SmoothingConfig | None
) -> tuple[float | None, bool]:
value, active = smooth_value(state.value, target, config)
state.value = value
state.active = active
return value, active
def reset_smoothing(state: SmoothingState, value: float | None = None) -> None:
state.value = value
state.active = False

305
src/dpg_gauges/state.py Normal file
View 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
View File

@@ -0,0 +1,76 @@
"""Lightweight theme and style presets."""
from __future__ import annotations
from dataclasses import dataclass, replace
from .exceptions import GaugeConfigError
from .types import Color, validate_color
@dataclass(frozen=True)
class GaugeStyle:
background_color: Color = (24, 24, 28, 255)
frame_color: Color = (58, 58, 66, 255)
value_color: Color = (82, 172, 255, 255)
text_color: Color = (235, 235, 240, 255)
muted_text_color: Color = (150, 150, 158, 255)
border_color: Color = (78, 78, 88, 255)
needle_color: Color = (255, 255, 255, 255)
tick_color: Color = (190, 190, 198, 255)
redline_color: Color = (255, 45, 45, 255)
def __post_init__(self) -> None:
for field_name, color in self.__dict__.items():
validate_color(color, name=field_name)
@dataclass(frozen=True)
class GaugeTheme:
name: str = "default"
style: GaugeStyle = GaugeStyle()
_PRESETS: dict[str, GaugeTheme] = {
"default": GaugeTheme(),
"dark": GaugeTheme("dark", GaugeStyle(background_color=(14, 16, 20, 255))),
"minimal": GaugeTheme(
"minimal",
GaugeStyle(
background_color=(0, 0, 0, 0),
frame_color=(210, 210, 216, 255),
value_color=(40, 40, 44, 255),
text_color=(30, 30, 34, 255),
muted_text_color=(110, 110, 116, 255),
border_color=(220, 220, 226, 255),
needle_color=(30, 30, 34, 255),
tick_color=(100, 100, 108, 255),
),
),
"motorsport": GaugeTheme(
"motorsport",
GaugeStyle(value_color=(255, 185, 0, 255), needle_color=(255, 35, 35, 255)),
),
"industrial": GaugeTheme(
"industrial",
GaugeStyle(background_color=(34, 36, 34, 255), value_color=(80, 220, 120, 255)),
),
}
def get_theme(theme: str | GaugeTheme | None = None) -> GaugeTheme:
if theme is None:
return _PRESETS["default"]
if isinstance(theme, GaugeTheme):
return theme
try:
return _PRESETS[theme]
except KeyError as exc:
msg = f"unknown gauge theme: {theme!r}"
raise GaugeConfigError(msg) from exc
def merge_style(base: GaugeStyle | None = None, **overrides: Color | None) -> GaugeStyle:
style = base or GaugeStyle()
clean = {key: value for key, value in overrides.items() if value is not None}
return replace(style, **clean)

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

@@ -0,0 +1,107 @@
"""Shared public types and dataclasses."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, TypeAlias
from .exceptions import GaugeConfigError
Tag: TypeAlias = str | int
Color: TypeAlias = tuple[int, int, int] | tuple[int, int, int, int]
GaugeValue: TypeAlias = float | int | bool | str | None
def validate_color(color: Color, *, name: str = "color") -> Color:
if len(color) not in (3, 4):
msg = f"{name} must contain 3 or 4 components"
raise GaugeConfigError(msg)
if any(component < 0 or component > 255 for component in color):
msg = f"{name} components must be between 0 and 255"
raise GaugeConfigError(msg)
return color
@dataclass(frozen=True)
class ThresholdBand:
start: float
end: float
color: Color
label: str | None = None
def __post_init__(self) -> None:
if self.start > self.end:
msg = "threshold start must be less than or equal to end"
raise GaugeConfigError(msg)
validate_color(self.color, name="threshold color")
@dataclass(frozen=True)
class GaugeMarker:
value: float
color: Color
label: str | None = None
thickness: float = 2.0
def __post_init__(self) -> None:
if self.thickness <= 0:
msg = "marker thickness must be greater than zero"
raise GaugeConfigError(msg)
validate_color(self.color, name="marker color")
@dataclass(frozen=True)
class AnimationConfig:
enabled: bool = True
duration: float = 0.15
easing: str = "linear"
def __post_init__(self) -> None:
if self.duration < 0:
msg = "animation duration must be non-negative"
raise GaugeConfigError(msg)
@dataclass(frozen=True)
class SmoothingConfig:
enabled: bool = False
alpha: float = 0.35
max_step: float | None = None
def __post_init__(self) -> None:
if not 0 < self.alpha <= 1:
msg = "smoothing alpha must be greater than 0 and at most 1"
raise GaugeConfigError(msg)
if self.max_step is not None and self.max_step <= 0:
msg = "smoothing max_step must be greater than zero"
raise GaugeConfigError(msg)
@dataclass(frozen=True)
class StatusState:
color: Color
text: str = ""
value_color: Color | None = None
def __post_init__(self) -> None:
validate_color(self.color, name="status color")
if self.value_color is not None:
validate_color(self.value_color, name="status value color")
@dataclass(frozen=True)
class GaugeStats:
min_value: float | None = None
max_value: float | None = None
average: float | None = None
samples: int = 0
def __post_init__(self) -> None:
if self.samples < 0:
msg = "stats samples must be non-negative"
raise GaugeConfigError(msg)
ValueFormatter: TypeAlias = Callable[[float], str]
Callback: TypeAlias = Callable[[Tag, GaugeValue, Any], None]

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

0
tests/.gitkeep Normal file
View File

117
tests/test_step1_core.py Normal file
View File

@@ -0,0 +1,117 @@
import pytest
import dpg_gauges as dpgg
from dpg_gauges.exceptions import GaugeConfigError, GaugeNotFoundError, GaugeValueError
from dpg_gauges.gauges import AnalogGaugeConfig, DigitalGaugeConfig
from dpg_gauges.scales import (
clamp_value,
clamped_threshold,
fill_fraction,
format_gauge_value,
generate_ticks,
normalize_value,
value_to_angle,
)
def test_public_api_exports_required_names() -> None:
required = {
"configure",
"GaugeTheme",
"GaugeStyle",
"AnimationConfig",
"SmoothingConfig",
"ThresholdBand",
"GaugeMarker",
"StatusState",
"GaugeValue",
"GaugeStats",
"analog_gauge",
"digital_gauge",
"bar_gauge",
"level_gauge",
"status_light",
"segmented_gauge",
"compass_gauge",
"thermometer_gauge",
"battery_gauge",
"multi_value_gauge",
"gauge_panel",
"gauge_grid",
"set_value",
"get_value",
"update_gauge",
"configure_gauge",
"set_thresholds",
"set_markers",
"set_show",
"delete_gauge",
"get_gauge_debug_state",
}
assert required.issubset(set(dpgg.__all__))
for name in required:
assert hasattr(dpgg, name)
def test_dataclass_construction_and_context_stub() -> None:
threshold = dpgg.ThresholdBand(10, 20, (255, 0, 0, 255), "hot")
marker = dpgg.GaugeMarker(50, (0, 255, 0), thickness=1.5)
config = AnalogGaugeConfig(
label="RPM",
min_value=0,
max_value=8000,
thresholds=[threshold],
markers=[marker],
animation=True,
smoothing=False,
)
assert config.thresholds == (threshold,)
assert config.markers == (marker,)
assert config.animation == dpgg.AnimationConfig(enabled=True)
assert config.smoothing == dpgg.SmoothingConfig(enabled=False)
with dpgg.digital_gauge(label="Speed", value=12.3) as digital:
assert isinstance(digital, DigitalGaugeConfig)
assert digital.label == "Speed"
def test_clamp_normalize_angle_and_fill_math() -> None:
assert clamp_value(150, 0, 100) == 100
assert normalize_value(25, 0, 100) == 0.25
assert fill_fraction(-10, 0, 100) == 0
assert value_to_angle(50, 0, 100, 225, -45) == 90
with pytest.raises(GaugeValueError):
clamp_value(float("nan"), 0, 100)
def test_tick_generation_and_formatting() -> None:
assert generate_ticks(0, 100, 5) == [0, 25, 50, 75, 100]
assert format_gauge_value(12.345, precision=1, unit="V") == "12.3 V"
assert format_gauge_value(float("inf"), precision=1) == "--"
with pytest.raises(GaugeConfigError):
generate_ticks(0, 100, 1)
def test_threshold_validation_and_clamping() -> None:
threshold = dpgg.ThresholdBand(-10, 120, (255, 128, 0, 255))
assert clamped_threshold(threshold, 0, 100) == (0, 100)
with pytest.raises(GaugeConfigError):
dpgg.ThresholdBand(20, 10, (255, 0, 0))
with pytest.raises(GaugeConfigError):
AnalogGaugeConfig(min_value=100, max_value=0)
def test_runtime_stubs_import_without_dpg_context() -> None:
dpgg.configure(debug=True)
with pytest.raises(GaugeNotFoundError):
dpgg.set_value("missing", 1)
with pytest.raises(GaugeNotFoundError):
dpgg.get_gauge_debug_state("missing")

View File

@@ -0,0 +1,162 @@
import pytest
import dpg_gauges as dpgg
from dpg_gauges.animation import advance_animation, interpolate, start_animation
from dpg_gauges.exceptions import GaugeConfigError, GaugeNotFoundError
from dpg_gauges.gauges import DigitalGaugeConfig
from dpg_gauges.sizing import (
SizeState,
effective_size,
requested_size,
square_gauge_rect,
stretch_gauge_rect,
update_measured_size,
)
from dpg_gauges.smoothing import SmoothingState, advance_smoothing, smooth_value
from dpg_gauges.state import DirtyFlags, clear_registry, get_gauge_state, register_gauge
from dpg_gauges.types import AnimationConfig, SmoothingConfig
@pytest.fixture(autouse=True)
def clean_registry() -> None:
clear_registry()
dpgg.configure()
def test_registry_register_get_and_delete() -> None:
with dpgg.digital_gauge(tag="speed", value=42, min_value=0, max_value=100) as config:
assert config.tag == "speed"
assert dpgg.get_value("speed") == 42
state = get_gauge_state("speed")
assert state.gauge_type == "digital"
assert state.clamped_value == 42
assert state.displayed_value == 42
with pytest.raises(GaugeConfigError):
register_gauge("digital", DigitalGaugeConfig(tag="speed"))
dpgg.delete_gauge("speed")
with pytest.raises(GaugeNotFoundError):
dpgg.get_value("speed")
def test_value_clamped_displayed_and_dirty_flags_are_separate() -> None:
dpgg.digital_gauge(tag="rpm", value=10, min_value=0, max_value=100, animation=True)
state = get_gauge_state("rpm")
state.dirty = DirtyFlags.NONE
dpgg.set_value("rpm", 150)
assert dpgg.get_value("rpm") == 150
assert state.clamped_value == 100
assert state.displayed_value == 10
assert state.animation_state.active
assert state.dirty & DirtyFlags.VALUE
assert state.dirty & DirtyFlags.DYNAMIC
assert state.dirty & DirtyFlags.TEXT
def test_update_gauge_marks_static_text_size_and_style_dirty() -> None:
dpgg.digital_gauge(tag="temp", value=20, min_value=0, max_value=100)
state = get_gauge_state("temp")
state.dirty = DirtyFlags.NONE
dpgg.update_gauge(
"temp",
label="Temperature",
unit="C",
min_value=-40,
max_value=140,
width=200,
style=state.config.style,
)
assert state.config.label == "Temperature"
assert state.config.unit == "C"
assert state.config.min_value == -40
assert state.dirty & DirtyFlags.STATIC
assert state.dirty & DirtyFlags.DYNAMIC
assert state.dirty & DirtyFlags.TEXT
assert state.dirty & DirtyFlags.SIZE
assert state.dirty & DirtyFlags.STYLE
def test_threshold_marker_and_show_runtime_updates() -> None:
dpgg.digital_gauge(tag="fuel", min_value=0, max_value=100)
threshold = dpgg.ThresholdBand(0, 15, (255, 0, 0))
marker = dpgg.GaugeMarker(50, (0, 255, 0))
dpgg.set_thresholds("fuel", [threshold])
dpgg.set_markers("fuel", [marker])
dpgg.set_show("fuel", False)
state = get_gauge_state("fuel")
assert state.config.thresholds == (threshold,)
assert state.config.markers == (marker,)
assert state.config.show is False
assert state.dirty & DirtyFlags.STATIC
assert state.dirty & DirtyFlags.SIZE
def test_non_finite_values_do_not_crash_state_helpers() -> None:
dpgg.digital_gauge(tag="boost", min_value=0, max_value=30)
dpgg.set_value("boost", float("nan"))
state = get_gauge_state("boost")
assert state.target_value != state.target_value
assert state.clamped_value is None
assert state.displayed_value is None
assert state.invalid_value is True
def test_sizing_preserves_last_nonzero_size_and_rects() -> None:
size = SizeState(requested=requested_size(100, 80))
assert update_measured_size(size, 320, 180) is True
assert effective_size(size) == (320, 180)
update_measured_size(size, 0, 0)
assert effective_size(size) == (320, 180)
assert square_gauge_rect(320, 180).width == 180
assert square_gauge_rect(320, 180).x == 70
assert stretch_gauge_rect(320, 180).width == 320
assert stretch_gauge_rect(320, 180).height == 180
def test_animation_interpolation_and_completion() -> None:
config = AnimationConfig(enabled=True, duration=1.0)
state = start_animation(0, 100, config, now=10.0)
assert interpolate(0, 100, 0.25) == 25
value, active = advance_animation(state, config, now=10.5)
assert value == 50
assert active is True
value, active = advance_animation(state, config, now=11.0)
assert value == 100
assert active is False
def test_smoothing_progression() -> None:
config = SmoothingConfig(enabled=True, alpha=0.5, max_step=10)
assert smooth_value(None, 100, config) == (100, False)
state = SmoothingState(value=0)
value, active = advance_smoothing(state, 100, config)
assert value == 10
assert active is True
def test_debug_state_reports_model_fields() -> None:
dpgg.digital_gauge(tag="debug", value=5, width=120, height=30)
debug = dpgg.get_gauge_debug_state("debug")
assert debug["gauge_type"] == "digital"
assert debug["target_value"] == 5
assert debug["clamped_value"] == 5
assert debug["measured_size"] == (120, 30)
assert "VALUE" in debug["dirty_names"]

168
uv.lock generated Normal file
View File

@@ -0,0 +1,168 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "dearpygui"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/40/22c00a3b3e47c609ece39b0e1970b8e031ac613201fee065dd93c9cbcb6c/dearpygui-2.3.1-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:e1a9ffbb845600ab4a171daf2d7e7a1895f5b4433eed248d4cab42766b55fdff", size = 2002951, upload-time = "2026-05-01T22:45:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/31/7d/b2b26e75437a6173e44a89a816d5f1df76537ca2f4b3206a2a787ac9d479/dearpygui-2.3.1-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:686423b4f8a30aaf57f1044ec4e3b087beb9cc9b25be812cf92f4e7feff44fd6", size = 2678639, upload-time = "2026-05-01T22:46:06.246Z" },
{ url = "https://files.pythonhosted.org/packages/00/90/efaa3dbcdab267638b0b65551c88ea1e994235b74345c8d995b8a0a9d59e/dearpygui-2.3.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:0259a2db7b6c82459ac1402b0d42388a7cd180cd97d7dcb31a5210725c3171d3", size = 2501986, upload-time = "2026-05-01T22:46:17.044Z" },
{ url = "https://files.pythonhosted.org/packages/b4/db/8f15dfe06d104746591c4763a5bba31369b86d861aa916101da8f633bfc6/dearpygui-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:76ee38696a8f1f3805751b1588c98d9bb6567b033b91cb0f58111fb9fe907b5b", size = 1881816, upload-time = "2026-05-01T22:45:48.044Z" },
{ url = "https://files.pythonhosted.org/packages/20/f1/6f30c7eafa25755105d0c26049a95c89cc5c82b68971e1cc8ffea36b748a/dearpygui-2.3.1-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:6b1efd675a358176b3b76c039587a543d407d8fbb2cc3643a527e0ab7c0f5577", size = 2002717, upload-time = "2026-05-01T22:46:00.242Z" },
{ url = "https://files.pythonhosted.org/packages/cc/03/fa76c31087b6ebbc13948de6d9d3bc9ed9946824b13d9d1089356b54249c/dearpygui-2.3.1-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:364b5b6a0953999fec764d529d9bb0b2cc6ab6ed06188e0ded28237c1475ca98", size = 2678552, upload-time = "2026-05-01T22:46:07.502Z" },
{ url = "https://files.pythonhosted.org/packages/f2/ef/657ec68a79664cd28d98d590eed85103c6ffc1e23e7d3c4e5aedb7ee887d/dearpygui-2.3.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:199dffebda245c9f59aa7c10c3b7f62963237c5f744150ad9be409b27a2661d3", size = 2501224, upload-time = "2026-05-01T22:46:18.8Z" },
{ url = "https://files.pythonhosted.org/packages/77/be/e67e5a04439f15e60cf596092a31b2a907272288954e47f5ced43c0b1678/dearpygui-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:26ed9197cdb30099c665bd1b23724e0dba60ad06b71344ecca7e52748c21874b", size = 1882080, upload-time = "2026-05-01T22:45:49.366Z" },
{ url = "https://files.pythonhosted.org/packages/42/db/56a293ad392b8dafdd3eb1a0c4d367efbde99aa6930359685fe270984dd1/dearpygui-2.3.1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:d2d29e72b031886893b32269e1478eff1fd651512e6c2bceda91059c3bc8ee0e", size = 2002694, upload-time = "2026-05-01T22:46:01.425Z" },
{ url = "https://files.pythonhosted.org/packages/cc/e0/df598671f40a7fffb8390946115d9e9cf12d3f4a52f5b9381bddc5699aee/dearpygui-2.3.1-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:e3f64c8d4cae68a3b5be1b7531d08400ccbf1e4ff03c7acfc85954f98993f864", size = 2678564, upload-time = "2026-05-01T22:46:08.706Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d1/87cfb577d4a1615e809e2f6e0fd720bd4ad6cd25e365ef2fd08a6e09c86e/dearpygui-2.3.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:0d8b7a5a04cd4a6e25dbf162aa0405cf12143a1c82b33e371c20e5ba8d9349f3", size = 2503196, upload-time = "2026-05-01T22:46:20.276Z" },
{ url = "https://files.pythonhosted.org/packages/b3/38/05f74181a59353bdc927b6ec46333714f633c5755f3d4bcda34d6eb100a5/dearpygui-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:7946722c50f1e08866dfdb78eb33dd4e67dea1a3cfe4400b159e1c96861116dc", size = 1882082, upload-time = "2026-05-01T22:45:50.915Z" },
{ url = "https://files.pythonhosted.org/packages/4a/08/68ce3ba941cf09bcf288f2603d7178c936d473172fd545e0665b885cb840/dearpygui-2.3.1-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:e2cbabd8d383308ea3520360e100d1e95c9a957cd26247431cd4920b23c3b56e", size = 2003034, upload-time = "2026-05-01T22:46:02.736Z" },
{ url = "https://files.pythonhosted.org/packages/f3/3f/ac003b6d636c9f5aae910a62d00b6c119403212cf2d7772d7c59ca8ef6dd/dearpygui-2.3.1-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:953442c7272e57f3686e393edc5cf6ce73cc2f47142739735dfabed403e33770", size = 2678545, upload-time = "2026-05-01T22:46:10.181Z" },
{ url = "https://files.pythonhosted.org/packages/1a/6b/657a2f1e2f604f356c39ed5b870aaed5d07fdeac3139fae2a455473b64ca/dearpygui-2.3.1-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:43a561b5dc589944a3a2b469e5e68f1ab35ae38b3dcdf1f813ed9d6e024153f8", size = 2501699, upload-time = "2026-05-01T22:46:21.497Z" },
{ url = "https://files.pythonhosted.org/packages/8c/56/93b2310891589063e0226cb07fe05e08fbdb3f409f80f9edefb1cebea84e/dearpygui-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:272cc52c66bc5a4a332f13a3af5e603070d47ca7cc4976e6a8c247594e2b3dd0", size = 1943067, upload-time = "2026-05-01T22:45:52.255Z" },
]
[[package]]
name = "dpg-gauges"
version = "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" },
]