step 3: add widget shell and renderer foundation

This commit is contained in:
2026-07-02 14:20:22 +02:00
parent 34fd60c34e
commit 41e44a23bc
13 changed files with 683 additions and 84 deletions

View File

@@ -2,27 +2,14 @@
from __future__ import annotations
from contextlib import nullcontext
from typing import Any, cast
from typing import Any
from .gauges import (
AnalogGaugeConfig,
BarGaugeConfig,
BatteryGaugeConfig,
CompassGaugeConfig,
DigitalGaugeConfig,
GaugeConfig,
LevelGaugeConfig,
MultiValueGaugeConfig,
SegmentedGaugeConfig,
StatusLightConfig,
ThermometerGaugeConfig,
)
from . import widget as _widget
from .gauges import GaugeConfig
from .state import (
configure_gauges,
delete_runtime_gauge,
get_runtime_value,
register_gauge,
set_runtime_markers,
set_runtime_show,
set_runtime_thresholds,
@@ -36,62 +23,18 @@ 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)
analog_gauge = _widget.analog_gauge
digital_gauge = _widget.digital_gauge
bar_gauge = _widget.bar_gauge
level_gauge = _widget.level_gauge
status_light = _widget.status_light
segmented_gauge = _widget.segmented_gauge
compass_gauge = _widget.compass_gauge
thermometer_gauge = _widget.thermometer_gauge
battery_gauge = _widget.battery_gauge
multi_value_gauge = _widget.multi_value_gauge
gauge_panel = _widget.gauge_panel
gauge_grid = _widget.gauge_grid
def set_value(tag: Tag, value: GaugeValue) -> None:

View File

@@ -10,7 +10,7 @@ from .types import Tag
def get_gauge_debug_state(tag: Tag) -> dict[str, Any]:
state = get_gauge_state(tag)
return {
debug = {
"tag": state.tag,
"gauge_type": state.gauge_type,
"target_value": state.target_value,
@@ -26,3 +26,6 @@ def get_gauge_debug_state(tag: Tag) -> dict[str, Any]:
"animation_active": state.animation_state.active,
"smoothing_active": state.smoothing_state.active,
}
if state.renderer is not None:
debug["renderer"] = state.renderer.debug_state()
return debug

View File

@@ -0,0 +1,44 @@
"""Bookkeeping for Dear PyGui drawlist layers."""
from __future__ import annotations
from dataclasses import dataclass, field
from .types import Tag
@dataclass
class DrawLayer:
name: str
item_tags: set[Tag] = field(default_factory=set)
def add(self, tag: Tag) -> Tag:
self.item_tags.add(tag)
return tag
def clear(self) -> None:
self.item_tags.clear()
@dataclass
class DrawLayers:
background: DrawLayer = field(default_factory=lambda: DrawLayer("background"))
static: DrawLayer = field(default_factory=lambda: DrawLayer("static"))
thresholds: DrawLayer = field(default_factory=lambda: DrawLayer("thresholds"))
dynamic: DrawLayer = field(default_factory=lambda: DrawLayer("dynamic"))
markers: DrawLayer = field(default_factory=lambda: DrawLayer("markers"))
text: DrawLayer = field(default_factory=lambda: DrawLayer("text"))
def all_layers(self) -> tuple[DrawLayer, ...]:
return (
self.background,
self.static,
self.thresholds,
self.dynamic,
self.markers,
self.text,
)
def clear(self) -> None:
for layer in self.all_layers():
layer.clear()

View File

@@ -0,0 +1,166 @@
"""Dear PyGui renderer foundation for gauge placeholders."""
from __future__ import annotations
import time
from typing import Any
import dearpygui.dearpygui as dpg
from .animation import advance_animation
from .draw_layers import DrawLayers
from .scales import format_gauge_value
from .sizing import effective_size, update_measured_size
from .smoothing import advance_smoothing
from .state import DirtyFlags, GaugeState, animation_config, size_state_for_gauge, update_state_size
from .themes import GaugeStyle
from .types import SmoothingConfig
class GaugeRenderer:
"""Small renderer that keeps a gauge drawlist populated between updates."""
def __init__(self, state: GaugeState) -> None:
self.state = state
self.layers = DrawLayers()
self._scheduled = False
def render(self) -> None:
state = self.state
self._measure()
self._advance_values()
self._redraw_placeholder()
state.dirty = DirtyFlags.NONE
if state.animation_state.active or state.smoothing_state.active:
self.schedule_frame()
def delete(self) -> None:
if dpg.does_item_exist(self.state.container_tag):
dpg.delete_item(self.state.container_tag)
def schedule_frame(self) -> None:
if self._scheduled:
return
self._scheduled = True
frame = dpg.get_frame_count() + 1
dpg.set_frame_callback(frame, self._frame_callback)
def debug_state(self) -> dict[str, Any]:
state = self.state
return {
"container_tag": state.container_tag,
"drawlist_tag": state.drawlist_tag,
"layers": {layer.name: tuple(layer.item_tags) for layer in self.layers.all_layers()},
"scheduled": self._scheduled,
}
def _frame_callback(self) -> None:
self._scheduled = False
self.state.dirty |= DirtyFlags.DYNAMIC | DirtyFlags.TEXT
self.render()
def _measure(self) -> None:
state = self.state
if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag):
return
width = dpg.get_item_width(state.drawlist_tag) or 0
height = dpg.get_item_height(state.drawlist_tag) or 0
size = size_state_for_gauge(state)
if update_measured_size(size, width, height):
update_state_size(state, size)
def _advance_values(self) -> None:
state = self.state
now = time.monotonic()
config = animation_config(state.config)
if state.animation_state.active:
state.displayed_value, active = advance_animation(state.animation_state, config, now)
state.animation_state.active = active
smoothing = _smoothing_config(state)
if not state.animation_state.active and smoothing is not None and smoothing.enabled:
state.displayed_value, _ = advance_smoothing(
state.smoothing_state, state.clamped_value, smoothing
)
def _redraw_placeholder(self) -> None:
state = self.state
if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag):
return
width, height = effective_size(size_state_for_gauge(state))
width = width or max(state.config.width, 1) or 180
height = height or max(state.config.height, 1) or 80
dpg.configure_item(state.drawlist_tag, width=width, height=height, show=state.config.show)
dpg.configure_item(state.container_tag, show=state.config.show)
dpg.delete_item(state.drawlist_tag, children_only=True)
self.layers.clear()
style = state.config.style or GaugeStyle()
background = self.layers.background.add(f"{state.tag}__background")
frame = self.layers.static.add(f"{state.tag}__frame")
dpg.draw_rectangle(
(0, 0),
(width, height),
parent=state.drawlist_tag,
tag=background,
color=style.border_color,
fill=style.background_color,
rounding=6,
thickness=1,
)
dpg.draw_rectangle(
(4, 4),
(max(width - 4, 4), max(height - 4, 4)),
parent=state.drawlist_tag,
tag=frame,
color=style.frame_color,
rounding=4,
thickness=1,
)
label_y = 10
if state.config.show_label and state.config.label:
label = self.layers.text.add(f"{state.tag}__label")
dpg.draw_text(
(12, label_y),
state.config.label,
parent=state.drawlist_tag,
tag=label,
color=style.muted_text_color,
size=14,
)
value = self._display_text()
value_tag = self.layers.dynamic.add(f"{state.tag}__value")
dpg.draw_text(
(12, max(height * 0.45 - 10, 24)),
value,
parent=state.drawlist_tag,
tag=value_tag,
color=style.value_color if not state.invalid_value else style.redline_color,
size=24,
)
def _display_text(self) -> str:
state = self.state
if not state.config.show_value:
return ""
if state.invalid_value or state.displayed_value is None:
return "--"
if state.config.format_value is not None:
return state.config.format_value(state.displayed_value)
unit = state.config.unit if state.config.show_unit else ""
return format_gauge_value(state.displayed_value, state.config.precision, unit)
def render_attached_gauge(state: GaugeState) -> None:
if state.renderer is not None:
state.renderer.render()
def _smoothing_config(state: GaugeState) -> SmoothingConfig | None:
smoothing = state.config.smoothing
if isinstance(smoothing, bool):
return SmoothingConfig(enabled=smoothing)
return smoothing

View File

@@ -216,6 +216,7 @@ def set_runtime_value(tag: Tag, value: GaugeValue, *, now: float = 0.0) -> None:
state.displayed_value = clamped
state.smoothing_state.value = clamped
state.dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT
render_runtime_gauge(state)
def get_runtime_value(tag: Tag) -> GaugeValue:
@@ -257,6 +258,7 @@ def update_runtime_gauge(tag: Tag, **config: Any) -> None:
dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT
state.dirty |= dirty
render_runtime_gauge(state)
def set_runtime_thresholds(
@@ -278,10 +280,17 @@ def set_runtime_show(tag: Tag, show: bool) -> None:
def delete_runtime_gauge(tag: Tag) -> None:
get_gauge_state(tag)
state = get_gauge_state(tag)
if state.renderer is not None:
state.renderer.delete()
del _registry[tag]
def render_runtime_gauge(state: GaugeState) -> None:
if state.renderer is not None:
state.renderer.render()
def update_state_size(state: GaugeState, size: SizeState) -> None:
state.measured_width = size.measured_width
state.measured_height = size.measured_height

View File

@@ -0,0 +1,227 @@
"""Dear PyGui widget shell creation helpers."""
from __future__ import annotations
from dataclasses import dataclass
from types import TracebackType
from typing import Any, Generic, 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 .types import Tag
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
renderer.render()
return GaugeContext(state)
def gauge_panel(**config: Any) -> LayoutContext:
if not _dpg_context_active:
return LayoutContext(None)
tag = config.pop("tag", 0)
label = config.pop("label", None)
tooltip = config.pop("tooltip", None)
container = dpg.add_child_window(
tag=tag,
label=label,
width=config.pop("width", 0),
height=config.pop("height", 0),
show=config.pop("show", True),
border=config.pop("border", True),
autosize_x=config.pop("autosize_x", False),
autosize_y=config.pop("autosize_y", False),
**config,
)
_add_tooltip(container, tooltip)
return LayoutContext(container)
def gauge_grid(**config: Any) -> LayoutContext:
if not _dpg_context_active:
return LayoutContext(None)
columns = max(int(config.pop("columns", 1)), 1)
container = dpg.add_group(
tag=config.pop("tag", 0),
width=config.pop("width", 0),
height=config.pop("height", 0),
show=config.pop("show", True),
horizontal=columns > 1,
**config,
)
return LayoutContext(container)
def analog_gauge(**config: Any) -> GaugeContext[AnalogGaugeConfig]:
return create_gauge_context("analog", AnalogGaugeConfig(**config))
def digital_gauge(**config: Any) -> GaugeContext[DigitalGaugeConfig]:
return create_gauge_context("digital", DigitalGaugeConfig(**config))
def bar_gauge(**config: Any) -> GaugeContext[BarGaugeConfig]:
return create_gauge_context("bar", BarGaugeConfig(**config))
def level_gauge(**config: Any) -> GaugeContext[LevelGaugeConfig]:
return create_gauge_context("level", LevelGaugeConfig(**config))
def status_light(**config: Any) -> GaugeContext[StatusLightConfig]:
return create_gauge_context("status_light", StatusLightConfig(**config))
def segmented_gauge(**config: Any) -> GaugeContext[SegmentedGaugeConfig]:
return create_gauge_context("segmented", SegmentedGaugeConfig(**config))
def compass_gauge(**config: Any) -> GaugeContext[CompassGaugeConfig]:
return create_gauge_context("compass", CompassGaugeConfig(**config))
def thermometer_gauge(**config: Any) -> GaugeContext[ThermometerGaugeConfig]:
return create_gauge_context("thermometer", ThermometerGaugeConfig(**config))
def battery_gauge(**config: Any) -> GaugeContext[BatteryGaugeConfig]:
return create_gauge_context("battery", BatteryGaugeConfig(**config))
def multi_value_gauge(**config: Any) -> GaugeContext[MultiValueGaugeConfig]:
return create_gauge_context("multi_value", MultiValueGaugeConfig(**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)