step 3: add widget shell and renderer foundation
This commit is contained in:
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Dear PyGui gauge widgets for dashboards and telemetry displays.
|
Dear PyGui gauge widgets for dashboards and telemetry displays.
|
||||||
|
|
||||||
Current implementation status: Step 2 logical state is implemented. Gauge creation functions build
|
Current implementation status: Step 3 widget shells are implemented. Gauge creation functions still
|
||||||
and register pure gauge state without requiring a Dear PyGui context; final widget shells and drawing
|
register pure gauge state without requiring a Dear PyGui context, and when a Dear PyGui context is
|
||||||
arrive in later steps.
|
active they create a group plus drawlist shell with placeholder frame and value text rendering.
|
||||||
|
|
||||||
Runtime value updates are intended for the Dear PyGui GUI thread once rendering is implemented.
|
Runtime value updates are intended for the Dear PyGui GUI thread. Attached gauges redraw immediately
|
||||||
|
for simple value/configuration changes.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
Step 2 completed and verified. Ready to start Step 3.
|
Step 3 completed and verified with automated checks. Ready to start Step 4.
|
||||||
|
|
||||||
## Completed steps
|
## Completed steps
|
||||||
|
|
||||||
@@ -28,7 +28,18 @@ Step 1 - Public API contract and pure core:
|
|||||||
|
|
||||||
## Current step
|
## Current step
|
||||||
|
|
||||||
Step 3 - Widget shell and renderer foundation.
|
Step 4 - Digital, bar, level, and status gauges.
|
||||||
|
|
||||||
|
Step 3 - Widget shell and renderer foundation:
|
||||||
|
|
||||||
|
Implemented during Step 3:
|
||||||
|
|
||||||
|
- Added Dear PyGui widget shell creation for gauge context managers when a DPG context is active.
|
||||||
|
- Kept pure registration behavior when no DPG context has been created.
|
||||||
|
- Added a renderer foundation that measures drawlist size, redraws stable placeholder background/frame/value text, schedules animation frames, and exposes renderer debug fields.
|
||||||
|
- Added draw layer bookkeeping.
|
||||||
|
- Added lightweight `gauge_panel` and `gauge_grid` containers.
|
||||||
|
- Added Step 3 examples and smoke tests.
|
||||||
|
|
||||||
Step 2 - State, sizing, animation, and smoothing model:
|
Step 2 - State, sizing, animation, and smoothing model:
|
||||||
|
|
||||||
@@ -58,7 +69,7 @@ Implemented during Step 2:
|
|||||||
|
|
||||||
## Known issues
|
## Known issues
|
||||||
|
|
||||||
None yet.
|
Manual visual example checks were not run in this headless session.
|
||||||
|
|
||||||
## Commands used
|
## Commands used
|
||||||
|
|
||||||
@@ -84,7 +95,14 @@ None yet.
|
|||||||
- Ran `uv run ruff format --check .`.
|
- Ran `uv run ruff format --check .`.
|
||||||
- Ran `uv run pyright`.
|
- Ran `uv run pyright`.
|
||||||
- Updated instructions to require a commit after each completed step.
|
- Updated instructions to require a commit after each completed step.
|
||||||
|
- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 3 changes.
|
||||||
|
- Implemented Step 3 widget shells, renderer foundation, draw layer bookkeeping, layout helpers, examples, smoke tests, README status update, and diagnostics renderer fields.
|
||||||
|
- Ran `uv run pytest`.
|
||||||
|
- Ran `uv run ruff check .`.
|
||||||
|
- Ran `uv run ruff format .`.
|
||||||
|
- Ran `uv run ruff format --check .`.
|
||||||
|
- Ran `uv run pyright`.
|
||||||
|
|
||||||
## Next action
|
## Next action
|
||||||
|
|
||||||
Implement Step 3.
|
Implement Step 4.
|
||||||
|
|||||||
25
examples/basic_analog.py
Normal file
25
examples/basic_analog.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
with dpg.window(label="Analog Gauge", width=320, height=320):
|
||||||
|
dpgg.analog_gauge(
|
||||||
|
tag="rpm", label="RPM", value=3250, max_value=8000, width=240, height=240
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges analog", width=360, height=360)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
25
examples/basic_digital.py
Normal file
25
examples/basic_digital.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
with dpg.window(label="Digital Gauge", width=320, height=180):
|
||||||
|
dpgg.digital_gauge(
|
||||||
|
tag="speed", label="Speed", value=72, unit="km/h", width=260, height=100
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges digital", width=360, height=220)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
35
examples/dashboard.py
Normal file
35
examples/dashboard.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
dpg.window(label="Dashboard", width=620, height=360),
|
||||||
|
dpgg.gauge_panel(label="Engine", width=580, height=260),
|
||||||
|
dpgg.gauge_grid(columns=3),
|
||||||
|
):
|
||||||
|
dpgg.analog_gauge(
|
||||||
|
tag="rpm", label="RPM", value=4200, max_value=8000, width=180, height=180
|
||||||
|
)
|
||||||
|
dpgg.digital_gauge(
|
||||||
|
tag="speed", label="Speed", value=88, unit="km/h", width=160, height=90
|
||||||
|
)
|
||||||
|
dpgg.digital_gauge(
|
||||||
|
tag="temp", label="Coolant", value=91, unit="C", width=160, height=90
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges dashboard", width=660, height=400)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
25
examples/sizing.py
Normal file
25
examples/sizing.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
with dpg.window(label="Sizing", width=520, height=300):
|
||||||
|
dpgg.digital_gauge(tag="fixed", label="Fixed", value=12.3, width=180, height=80)
|
||||||
|
dpgg.digital_gauge(tag="wide", label="Fill width", value=45.6, width=-1, height=80)
|
||||||
|
dpgg.analog_gauge(tag="square", label="Square", value=60, width=180, height=180)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges sizing", width=560, height=340)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -2,27 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import nullcontext
|
from typing import Any
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
from .gauges import (
|
from . import widget as _widget
|
||||||
AnalogGaugeConfig,
|
from .gauges import GaugeConfig
|
||||||
BarGaugeConfig,
|
|
||||||
BatteryGaugeConfig,
|
|
||||||
CompassGaugeConfig,
|
|
||||||
DigitalGaugeConfig,
|
|
||||||
GaugeConfig,
|
|
||||||
LevelGaugeConfig,
|
|
||||||
MultiValueGaugeConfig,
|
|
||||||
SegmentedGaugeConfig,
|
|
||||||
StatusLightConfig,
|
|
||||||
ThermometerGaugeConfig,
|
|
||||||
)
|
|
||||||
from .state import (
|
from .state import (
|
||||||
configure_gauges,
|
configure_gauges,
|
||||||
delete_runtime_gauge,
|
delete_runtime_gauge,
|
||||||
get_runtime_value,
|
get_runtime_value,
|
||||||
register_gauge,
|
|
||||||
set_runtime_markers,
|
set_runtime_markers,
|
||||||
set_runtime_show,
|
set_runtime_show,
|
||||||
set_runtime_thresholds,
|
set_runtime_thresholds,
|
||||||
@@ -36,62 +23,18 @@ def configure(**config: Any) -> None:
|
|||||||
configure_gauges(**config)
|
configure_gauges(**config)
|
||||||
|
|
||||||
|
|
||||||
def analog_gauge(**config: Any) -> nullcontext[AnalogGaugeConfig]:
|
analog_gauge = _widget.analog_gauge
|
||||||
state = register_gauge("analog", AnalogGaugeConfig(**config))
|
digital_gauge = _widget.digital_gauge
|
||||||
return nullcontext(cast(AnalogGaugeConfig, state.config))
|
bar_gauge = _widget.bar_gauge
|
||||||
|
level_gauge = _widget.level_gauge
|
||||||
|
status_light = _widget.status_light
|
||||||
def digital_gauge(**config: Any) -> nullcontext[DigitalGaugeConfig]:
|
segmented_gauge = _widget.segmented_gauge
|
||||||
state = register_gauge("digital", DigitalGaugeConfig(**config))
|
compass_gauge = _widget.compass_gauge
|
||||||
return nullcontext(cast(DigitalGaugeConfig, state.config))
|
thermometer_gauge = _widget.thermometer_gauge
|
||||||
|
battery_gauge = _widget.battery_gauge
|
||||||
|
multi_value_gauge = _widget.multi_value_gauge
|
||||||
def bar_gauge(**config: Any) -> nullcontext[BarGaugeConfig]:
|
gauge_panel = _widget.gauge_panel
|
||||||
state = register_gauge("bar", BarGaugeConfig(**config))
|
gauge_grid = _widget.gauge_grid
|
||||||
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:
|
def set_value(tag: Tag, value: GaugeValue) -> None:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from .types import Tag
|
|||||||
|
|
||||||
def get_gauge_debug_state(tag: Tag) -> dict[str, Any]:
|
def get_gauge_debug_state(tag: Tag) -> dict[str, Any]:
|
||||||
state = get_gauge_state(tag)
|
state = get_gauge_state(tag)
|
||||||
return {
|
debug = {
|
||||||
"tag": state.tag,
|
"tag": state.tag,
|
||||||
"gauge_type": state.gauge_type,
|
"gauge_type": state.gauge_type,
|
||||||
"target_value": state.target_value,
|
"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,
|
"animation_active": state.animation_state.active,
|
||||||
"smoothing_active": state.smoothing_state.active,
|
"smoothing_active": state.smoothing_state.active,
|
||||||
}
|
}
|
||||||
|
if state.renderer is not None:
|
||||||
|
debug["renderer"] = state.renderer.debug_state()
|
||||||
|
return debug
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ def set_runtime_value(tag: Tag, value: GaugeValue, *, now: float = 0.0) -> None:
|
|||||||
state.displayed_value = clamped
|
state.displayed_value = clamped
|
||||||
state.smoothing_state.value = clamped
|
state.smoothing_state.value = clamped
|
||||||
state.dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT
|
state.dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT
|
||||||
|
render_runtime_gauge(state)
|
||||||
|
|
||||||
|
|
||||||
def get_runtime_value(tag: Tag) -> GaugeValue:
|
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
|
dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT
|
||||||
|
|
||||||
state.dirty |= dirty
|
state.dirty |= dirty
|
||||||
|
render_runtime_gauge(state)
|
||||||
|
|
||||||
|
|
||||||
def set_runtime_thresholds(
|
def set_runtime_thresholds(
|
||||||
@@ -278,10 +280,17 @@ def set_runtime_show(tag: Tag, show: bool) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def delete_runtime_gauge(tag: Tag) -> 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]
|
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:
|
def update_state_size(state: GaugeState, size: SizeState) -> None:
|
||||||
state.measured_width = size.measured_width
|
state.measured_width = size.measured_width
|
||||||
state.measured_height = size.measured_height
|
state.measured_height = size.measured_height
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
78
tests/test_step3_widget_renderer.py
Normal file
78
tests/test_step3_widget_renderer.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
from dpg_gauges.state import clear_registry, get_gauge_state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dpg_context() -> Generator[None]:
|
||||||
|
clear_registry()
|
||||||
|
dpgg.configure()
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
clear_registry()
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dpg_gauge_shell_registers_before_yield(dpg_context: None) -> None:
|
||||||
|
with (
|
||||||
|
dpg.window(label="Smoke"),
|
||||||
|
dpgg.digital_gauge(tag="speed", label="Speed", value=42, width=180, height=80) as config,
|
||||||
|
):
|
||||||
|
state = get_gauge_state("speed")
|
||||||
|
assert config.tag == "speed"
|
||||||
|
assert state.renderer is not None
|
||||||
|
assert dpg.does_item_exist(state.container_tag)
|
||||||
|
assert state.drawlist_tag is not None
|
||||||
|
assert dpg.does_item_exist(state.drawlist_tag)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_update_redraws_attached_gauge(dpg_context: None) -> None:
|
||||||
|
with dpg.window(label="Smoke"):
|
||||||
|
dpgg.digital_gauge(tag="rpm", label="RPM", value=1000, max_value=8000, width=200, height=90)
|
||||||
|
|
||||||
|
dpgg.set_value("rpm", 2500)
|
||||||
|
|
||||||
|
state = get_gauge_state("rpm")
|
||||||
|
debug = dpgg.get_gauge_debug_state("rpm")
|
||||||
|
assert state.displayed_value == 2500
|
||||||
|
assert state.dirty == 0
|
||||||
|
assert debug["renderer"]["drawlist_tag"] == "rpm__drawlist"
|
||||||
|
assert "rpm__value" in debug["renderer"]["layers"]["dynamic"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hidden_gauge_preserves_configured_size(dpg_context: None) -> None:
|
||||||
|
with dpg.window(label="Hidden"):
|
||||||
|
dpgg.digital_gauge(tag="hidden", value=1, width=160, height=70, show=False)
|
||||||
|
|
||||||
|
state = get_gauge_state("hidden")
|
||||||
|
assert state.measured_width == 160
|
||||||
|
assert state.measured_height == 70
|
||||||
|
assert state.last_nonzero_width == 160
|
||||||
|
assert state.last_nonzero_height == 70
|
||||||
|
|
||||||
|
dpgg.set_show("hidden", True)
|
||||||
|
assert dpg.does_item_exist(state.container_tag)
|
||||||
|
assert state.config.show is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_and_grid_are_lightweight_containers(dpg_context: None) -> None:
|
||||||
|
with (
|
||||||
|
dpg.window(label="Dashboard"),
|
||||||
|
dpgg.gauge_panel(tag="engine", label="Engine", width=300, height=180) as panel,
|
||||||
|
dpgg.gauge_grid(tag="engine_grid", columns=2) as grid,
|
||||||
|
):
|
||||||
|
assert panel == "engine"
|
||||||
|
assert grid == "engine_grid"
|
||||||
|
dpgg.digital_gauge(tag="temp", label="Temp", value=90, width=120, height=60)
|
||||||
|
|
||||||
|
assert dpg.does_item_exist("engine")
|
||||||
|
assert dpg.does_item_exist("engine_grid")
|
||||||
|
assert get_gauge_state("temp").renderer is not None
|
||||||
Reference in New Issue
Block a user