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

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