step 2: add gauge state sizing and animation model
This commit is contained in:
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
Reference in New Issue
Block a user