Files
dpg-gauges/src/dpg_gauges/widget.py

633 lines
19 KiB
Python

"""Dear PyGui widget shell creation helpers."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import TracebackType
from typing import Any, Generic, Literal, TypeVar, cast
import dearpygui.dearpygui as dpg
from .gauges import (
AnalogGaugeConfig,
BarGaugeConfig,
BatteryGaugeConfig,
CompassGaugeConfig,
DigitalGaugeConfig,
GaugeConfig,
LevelGaugeConfig,
MultiValueGaugeConfig,
SegmentedGaugeConfig,
StatusLightConfig,
ThermometerGaugeConfig,
)
from .renderer import GaugeRenderer
from .state import GaugeState, register_gauge
from .themes import GaugeStyle, GaugeTheme
from .types import (
AnimationConfig,
Callback,
GaugeMarker,
GaugeValue,
SmoothingConfig,
StatusState,
Tag,
ThresholdBand,
ValueFormatter,
)
ConfigT = TypeVar("ConfigT", bound=GaugeConfig)
_dpg_context_active = False
_original_create_context = dpg.create_context
_original_destroy_context = dpg.destroy_context
def _install_context_tracking() -> None:
if getattr(dpg.create_context, "__dpg_gauges_wrapped__", False):
return
def create_context(*args: Any, **kwargs: Any) -> Any:
global _dpg_context_active
result = _original_create_context(*args, **kwargs)
_dpg_context_active = True
return result
def destroy_context(*args: Any, **kwargs: Any) -> Any:
global _dpg_context_active
try:
return _original_destroy_context(*args, **kwargs)
finally:
_dpg_context_active = False
create_context.__dpg_gauges_wrapped__ = True # type: ignore[attr-defined]
destroy_context.__dpg_gauges_wrapped__ = True # type: ignore[attr-defined]
dpg.create_context = create_context
dpg.destroy_context = destroy_context
_install_context_tracking()
@dataclass
class GaugeContext(Generic[ConfigT]):
state: GaugeState
def __enter__(self) -> ConfigT:
if _dpg_context_active and dpg.does_item_exist(self.state.container_tag):
dpg.push_container_stack(self.state.container_tag)
return cast(ConfigT, self.state.config)
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: TracebackType | None,
) -> None:
if _dpg_context_active and dpg.does_item_exist(self.state.container_tag):
dpg.pop_container_stack()
@dataclass
class LayoutContext:
tag: Tag | None
def __enter__(self) -> Tag | None:
if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag):
dpg.push_container_stack(self.tag)
return self.tag
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: TracebackType | None,
) -> None:
if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag):
dpg.pop_container_stack()
def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[ConfigT]:
if not _dpg_context_active:
return GaugeContext(register_gauge(gauge_type, config))
tag = config.tag
container_tag = tag if tag is not None else None
drawlist_tag = f"{tag}__drawlist" if tag is not None else None
width = config.width
height = config.height or _default_height(gauge_type)
if height == -1:
height = _default_height(gauge_type)
container = dpg.add_group(
tag=container_tag or 0,
width=width,
height=height,
show=config.show,
user_data=config.user_data,
)
drawlist = dpg.add_drawlist(
max(width, 1) if width > 0 else _default_width(gauge_type),
max(height, 1),
tag=drawlist_tag or 0,
parent=container,
show=config.show,
)
_add_tooltip(container, config.tooltip)
state = register_gauge(
gauge_type,
config,
container_tag=container,
drawlist_tag=drawlist,
text_tags={},
)
renderer = GaugeRenderer(state)
state.renderer = renderer
if config.callback is not None:
dpg.configure_item(drawlist, callback=_item_callback, user_data=state.tag)
renderer.render()
return GaugeContext(state)
def gauge_panel(
*,
tag: Tag | None = None,
label: str | None = None,
width: int = 0,
height: int = 0,
show: bool = True,
border: bool = True,
autosize_x: bool = False,
autosize_y: bool = False,
tooltip: str | None = None,
**config: Any,
) -> LayoutContext:
"""Create a lightweight child-window container for related gauges."""
if not _dpg_context_active:
return LayoutContext(None)
container = dpg.add_child_window(
tag=tag or 0,
label=label or "",
width=width,
height=height,
show=show,
border=border,
autosize_x=autosize_x,
autosize_y=autosize_y,
**config,
)
_add_tooltip(container, tooltip)
return LayoutContext(container)
def gauge_grid(
*,
columns: int = 1,
tag: Tag | None = None,
width: int = 0,
height: int = 0,
show: bool = True,
**config: Any,
) -> LayoutContext:
"""Create a lightweight horizontal gauge layout container."""
if not _dpg_context_active:
return LayoutContext(None)
columns = max(int(columns), 1)
container = dpg.add_group(
tag=tag or 0,
width=width,
height=height,
show=show,
horizontal=columns > 1,
**config,
)
return LayoutContext(container)
def analog_gauge(
*,
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] = (),
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,
**config: Any,
) -> GaugeContext[AnalogGaugeConfig]:
"""Create an analog dial gauge with ticks, needle, thresholds, and markers."""
return create_gauge_context("analog", AnalogGaugeConfig(**_config_from_locals(locals())))
def digital_gauge(
*,
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] = (),
**config: Any,
) -> GaugeContext[DigitalGaugeConfig]:
"""Create a large numeric readout gauge."""
return create_gauge_context("digital", DigitalGaugeConfig(**_config_from_locals(locals())))
def bar_gauge(
*,
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] = (),
orientation: Literal["horizontal", "vertical"] = "horizontal",
fill_mode: Literal["clamp", "centered"] = "clamp",
corner_radius: float = 4.0,
show_scale: bool = True,
stretch: bool = True,
**config: Any,
) -> GaugeContext[BarGaugeConfig]:
"""Create a horizontal or vertical fill bar gauge."""
return create_gauge_context("bar", BarGaugeConfig(**_config_from_locals(locals())))
def level_gauge(
*,
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] = (),
orientation: Literal["horizontal", "vertical"] = "vertical",
fill_mode: Literal["clamp", "centered"] = "clamp",
corner_radius: float = 4.0,
show_scale: bool = True,
stretch: bool = True,
**config: Any,
) -> GaugeContext[LevelGaugeConfig]:
"""Create a tank or level-style vertical gauge."""
return create_gauge_context("level", LevelGaugeConfig(**_config_from_locals(locals())))
def status_light(
*,
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] = (),
state: str | bool | int = False,
states: Mapping[Any, StatusState] | None = None,
blink: bool = False,
pulse: bool = False,
**config: Any,
) -> GaugeContext[StatusLightConfig]:
"""Create an LED-style status indicator gauge."""
return create_gauge_context("status_light", StatusLightConfig(**_config_from_locals(locals())))
def segmented_gauge(
*,
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] = (),
orientation: Literal["horizontal", "vertical"] = "horizontal",
fill_mode: Literal["clamp", "centered"] = "clamp",
corner_radius: float = 4.0,
show_scale: bool = True,
stretch: bool = True,
segments: int = 10,
**config: Any,
) -> GaugeContext[SegmentedGaugeConfig]:
"""Create a segmented strip gauge for shift-light or cell-style displays."""
return create_gauge_context("segmented", SegmentedGaugeConfig(**_config_from_locals(locals())))
def compass_gauge(
*,
tag: Tag | None = None,
label: str | None = None,
value: GaugeValue = None,
min_value: float = 0.0,
max_value: float = 360.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] = (),
start_angle: float = 90.0,
end_angle: float = -270.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,
**config: Any,
) -> GaugeContext[CompassGaugeConfig]:
"""Create a compass gauge that wraps numeric headings to 0-360 degrees."""
return create_gauge_context("compass", CompassGaugeConfig(**_config_from_locals(locals())))
def thermometer_gauge(
*,
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] = (),
orientation: Literal["horizontal", "vertical"] = "vertical",
fill_mode: Literal["clamp", "centered"] = "clamp",
corner_radius: float = 4.0,
show_scale: bool = True,
stretch: bool = True,
**config: Any,
) -> GaugeContext[ThermometerGaugeConfig]:
"""Create a thermometer-style temperature gauge."""
return create_gauge_context(
"thermometer", ThermometerGaugeConfig(**_config_from_locals(locals()))
)
def battery_gauge(
*,
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] = (),
orientation: Literal["horizontal", "vertical"] = "vertical",
fill_mode: Literal["clamp", "centered"] = "clamp",
corner_radius: float = 4.0,
show_scale: bool = True,
stretch: bool = True,
charging: bool | None = None,
**config: Any,
) -> GaugeContext[BatteryGaugeConfig]:
"""Create a battery-style percentage or value gauge."""
return create_gauge_context("battery", BatteryGaugeConfig(**_config_from_locals(locals())))
def multi_value_gauge(
*,
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] = (),
values: Mapping[str, GaugeValue] | None = None,
**config: Any,
) -> GaugeContext[MultiValueGaugeConfig]:
"""Create a compact grouped key/value readout gauge."""
return create_gauge_context(
"multi_value", MultiValueGaugeConfig(**_config_from_locals(locals()))
)
def _config_from_locals(values: dict[str, Any]) -> dict[str, Any]:
config = dict(values.pop("config"))
config.update(values)
return config
def _default_width(gauge_type: str) -> int:
return 220 if gauge_type in {"analog", "compass"} else 180
def _default_height(gauge_type: str) -> int:
return 220 if gauge_type in {"analog", "compass"} else 80
def _add_tooltip(parent: Tag, tooltip: str | None) -> None:
if not tooltip:
return
tooltip_tag = dpg.add_tooltip(parent)
dpg.add_text(tooltip, parent=tooltip_tag)
def _item_callback(_sender: Tag, _app_data: object, user_data: Tag) -> None:
from .state import get_gauge_state
state = get_gauge_state(user_data)
if state.config.callback is not None:
state.config.callback(state.tag, state.target_value, state.config.user_data)