step 4: add digital bar level and status gauges

This commit is contained in:
2026-07-02 14:35:13 +02:00
parent 41e44a23bc
commit d638c512e9
11 changed files with 516 additions and 21 deletions

View File

@@ -2,9 +2,13 @@
Dear PyGui gauge widgets for dashboards and telemetry displays.
Current implementation status: Step 3 widget shells are implemented. Gauge creation functions still
register pure gauge state without requiring a Dear PyGui context, and when a Dear PyGui context is
active they create a group plus drawlist shell with placeholder frame and value text rendering.
Current implementation status: Step 4 simple gauge rendering is implemented. Digital, bar, level,
and status gauges render Dear PyGui drawlist visuals; bar and level gauges draw threshold bands and
markers. Gauge creation functions still register pure gauge state without requiring a Dear PyGui
context, and when a Dear PyGui context is active they create a group plus drawlist shell.
Runtime value updates are intended for the Dear PyGui GUI thread. Attached gauges redraw immediately
for simple value/configuration changes.
Analog, segmented, compass, thermometer, battery, and multi-value visuals are scheduled for later
steps and may still use placeholder rendering.

View File

@@ -2,7 +2,7 @@
## Current status
Step 3 completed and verified with automated checks. Ready to start Step 4.
Step 4 completed and verified with automated checks. Step 4 commit is pending.
## Completed steps
@@ -30,6 +30,15 @@ Step 1 - Public API contract and pure core:
Step 4 - Digital, bar, level, and status gauges.
Implemented during Step 4:
- Added digital gauge drawlist rendering with formatted value text.
- Added bar and level gauge rendering with horizontal/vertical fill geometry.
- Added threshold band and marker drawing for bar/level gauges.
- Added status light rendering with configured status state colors and text.
- Added Dear PyGui item callback wiring for gauges with `callback=`.
- Added Step 4 examples and tests for geometry, runtime updates, thresholds, markers, and status states.
Step 3 - Widget shell and renderer foundation:
Implemented during Step 3:
@@ -69,7 +78,7 @@ Implemented during Step 2:
## Known issues
Manual visual example checks were not run in this headless session.
Manual visual example checks for Steps 3 and 4 were not run in this headless session.
## Commands used
@@ -102,7 +111,14 @@ Manual visual example checks were not run in this headless session.
- Ran `uv run ruff format .`.
- Ran `uv run ruff format --check .`.
- Ran `uv run pyright`.
- Committed Step 3 with `git commit -m "step 3: add widget shell and renderer foundation"` before starting Step 4.
- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 4 changes.
- Implemented Step 4 digital, bar, level, and status rendering, threshold/marker drawing, callback wiring, examples, README status update, and tests.
- Ran `uv run pytest`.
- Ran `uv run ruff check .`.
- Ran `uv run ruff format --check .`.
- Ran `uv run pyright`.
## Next action
Implement Step 4.
Commit Step 4, then implement Step 5.

33
examples/basic_bar.py Normal file
View File

@@ -0,0 +1,33 @@
# pyright: reportGeneralTypeIssues=false
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
def main() -> None:
dpg.create_context()
try:
with dpg.window(label="Bar Gauge", width=360, height=180):
dpgg.bar_gauge(
tag="boost",
label="Boost",
value=18.5,
min_value=0,
max_value=30,
unit="psi",
precision=1,
width=300,
height=100,
)
dpg.create_viewport(title="dpg-gauges bar", width=400, height=220)
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
finally:
dpg.destroy_context()
if __name__ == "__main__":
main()

30
examples/basic_level.py Normal file
View File

@@ -0,0 +1,30 @@
# pyright: reportGeneralTypeIssues=false
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
def main() -> None:
dpg.create_context()
try:
with dpg.window(label="Level Gauge", width=260, height=320):
dpgg.level_gauge(
tag="fuel",
label="Fuel",
value=68,
unit="%",
width=180,
height=240,
)
dpg.create_viewport(title="dpg-gauges level", width=300, height=360)
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
finally:
dpg.destroy_context()
if __name__ == "__main__":
main()

35
examples/basic_status.py Normal file
View 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:
states = {
"ok": dpgg.StatusState((0, 220, 120, 255), "OK"),
"warn": dpgg.StatusState((255, 190, 0, 255), "WARN"),
"fault": dpgg.StatusState((255, 45, 45, 255), "FAULT"),
}
with dpg.window(label="Status Light", width=300, height=160):
dpgg.status_light(
tag="ecu",
label="ECU",
state="ok",
states=states,
width=240,
height=80,
)
dpg.create_viewport(title="dpg-gauges status", width=340, height=200)
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
finally:
dpg.destroy_context()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,37 @@
# pyright: reportGeneralTypeIssues=false
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
def main() -> None:
dpg.create_context()
try:
thresholds = [
dpgg.ThresholdBand(0, 30, (60, 180, 90, 150), "normal"),
dpgg.ThresholdBand(70, 100, (255, 80, 45, 150), "hot"),
]
markers = [dpgg.GaugeMarker(85, (255, 255, 255, 255), "limit", 3)]
with dpg.window(label="Thresholds and Markers", width=420, height=220):
dpgg.bar_gauge(
tag="coolant",
label="Coolant",
value=76,
unit="C",
width=340,
height=100,
thresholds=thresholds,
markers=markers,
)
dpg.create_viewport(title="dpg-gauges thresholds", width=460, height=260)
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
finally:
dpg.destroy_context()
if __name__ == "__main__":
main()

View File

@@ -1,20 +1,23 @@
"""Dear PyGui renderer foundation for gauge placeholders."""
"""Dear PyGui renderer foundation for gauge drawlists."""
from __future__ import annotations
import time
from typing import Any
from typing import Any, cast
import dearpygui.dearpygui as dpg
from .animation import advance_animation
from .draw_layers import DrawLayers
from .scales import format_gauge_value
from .gauges import BarGaugeConfig, LevelGaugeConfig, StatusLightConfig
from .scales import fill_fraction, fill_span, format_gauge_value, normalized_span
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
from .types import SmoothingConfig, StatusState, Tag
Rect = tuple[float, float, float, float]
class GaugeRenderer:
@@ -29,7 +32,7 @@ class GaugeRenderer:
state = self.state
self._measure()
self._advance_values()
self._redraw_placeholder()
self._redraw()
state.dirty = DirtyFlags.NONE
if state.animation_state.active or state.smoothing_state.active:
self.schedule_frame()
@@ -63,7 +66,6 @@ class GaugeRenderer:
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)
@@ -83,7 +85,7 @@ class GaugeRenderer:
state.smoothing_state, state.clamped_value, smoothing
)
def _redraw_placeholder(self) -> None:
def _redraw(self) -> None:
state = self.state
if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag):
return
@@ -97,12 +99,25 @@ class GaugeRenderer:
self.layers.clear()
style = state.config.style or GaugeStyle()
self._draw_frame(width, height, style)
if state.gauge_type == "digital":
self._draw_digital(width, height, style)
elif state.gauge_type in {"bar", "level"}:
self._draw_bar_or_level(width, height, style)
elif state.gauge_type == "status_light":
self._draw_status_light(width, height, style)
else:
self._draw_placeholder_text(width, height, style)
def _draw_frame(self, width: int, height: int, style: GaugeStyle) -> None:
state = self.state
drawlist = _drawlist_tag(state)
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,
parent=drawlist,
tag=background,
color=style.border_color,
fill=style.background_color,
@@ -112,36 +127,200 @@ class GaugeRenderer:
dpg.draw_rectangle(
(4, 4),
(max(width - 4, 4), max(height - 4, 4)),
parent=state.drawlist_tag,
parent=drawlist,
tag=frame,
color=style.frame_color,
rounding=4,
thickness=1,
)
label_y = 10
def _draw_label(self, style: GaugeStyle, *, x: float = 12, y: float = 10) -> None:
state = self.state
if state.config.show_label and state.config.label:
drawlist = _drawlist_tag(state)
label = self.layers.text.add(f"{state.tag}__label")
dpg.draw_text(
(12, label_y),
(x, y),
state.config.label,
parent=state.drawlist_tag,
parent=drawlist,
tag=label,
color=style.muted_text_color,
size=14,
)
value = self._display_text()
def _draw_digital(self, _width: int, height: int, style: GaugeStyle) -> None:
self._draw_label(style)
state = self.state
drawlist = _drawlist_tag(state)
value_tag = self.layers.dynamic.add(f"{state.tag}__value")
size = max(min(height * 0.34, 34), 20)
dpg.draw_text(
(12, max((height - size) * 0.52, 26)),
self._display_text(),
parent=drawlist,
tag=value_tag,
color=style.value_color if not state.invalid_value else style.redline_color,
size=size,
)
def _draw_placeholder_text(self, _width: int, height: int, style: GaugeStyle) -> None:
self._draw_label(style)
state = self.state
drawlist = _drawlist_tag(state)
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,
self._display_text(),
parent=drawlist,
tag=value_tag,
color=style.value_color if not state.invalid_value else style.redline_color,
size=24,
)
def _draw_bar_or_level(self, width: int, height: int, style: GaugeStyle) -> None:
state = self.state
config = state.config
if not isinstance(config, BarGaugeConfig | LevelGaugeConfig):
return
self._draw_label(style)
bar = _bar_rect(width, height, config.orientation)
self._draw_bar_track(bar, style, config.corner_radius)
self._draw_thresholds(bar, config.orientation, config.corner_radius)
if state.displayed_value is not None and not state.invalid_value:
start, end = fill_span(
state.displayed_value,
config.min_value,
config.max_value,
fill_mode=config.fill_mode,
clamp=config.clamp,
)
self._draw_bar_fill(_oriented_rect(bar, start, end, config.orientation), style)
self._draw_markers(bar, config.orientation)
if config.show_value:
drawlist = _drawlist_tag(state)
value_tag = self.layers.text.add(f"{state.tag}__value")
dpg.draw_text(
(12, height - 24),
self._display_text(),
parent=drawlist,
tag=value_tag,
color=style.text_color,
size=14,
)
def _draw_bar_track(self, rect: Rect, style: GaugeStyle, rounding: float) -> None:
drawlist = _drawlist_tag(self.state)
tag = self.layers.static.add(f"{self.state.tag}__bar_track")
dpg.draw_rectangle(
(rect[0], rect[1]),
(rect[2], rect[3]),
parent=drawlist,
tag=tag,
color=style.border_color,
fill=style.frame_color,
rounding=rounding,
thickness=1,
)
def _draw_bar_fill(self, rect: Rect, style: GaugeStyle) -> None:
if rect[2] <= rect[0] or rect[3] <= rect[1]:
return
drawlist = _drawlist_tag(self.state)
tag = self.layers.dynamic.add(f"{self.state.tag}__bar_fill")
dpg.draw_rectangle(
(rect[0], rect[1]),
(rect[2], rect[3]),
parent=drawlist,
tag=tag,
color=style.value_color,
fill=style.value_color,
rounding=2,
thickness=1,
)
def _draw_thresholds(self, rect: Rect, orientation: str, rounding: float) -> None:
state = self.state
drawlist = _drawlist_tag(state)
for index, threshold in enumerate(state.config.thresholds):
start, end = normalized_span(
threshold.start, threshold.end, state.config.min_value, state.config.max_value
)
threshold_rect = _oriented_rect(rect, start, end, orientation)
if threshold_rect[2] <= threshold_rect[0] or threshold_rect[3] <= threshold_rect[1]:
continue
tag = self.layers.thresholds.add(f"{state.tag}__threshold_{index}")
dpg.draw_rectangle(
(threshold_rect[0], threshold_rect[1]),
(threshold_rect[2], threshold_rect[3]),
parent=drawlist,
tag=tag,
color=threshold.color,
fill=threshold.color,
rounding=rounding,
thickness=1,
)
def _draw_markers(self, rect: Rect, orientation: str) -> None:
state = self.state
drawlist = _drawlist_tag(state)
for index, marker in enumerate(state.config.markers):
fraction = fill_fraction(
marker.value, state.config.min_value, state.config.max_value, clamp=True
)
tag = self.layers.markers.add(f"{state.tag}__marker_{index}")
if orientation == "vertical":
y = rect[3] - (rect[3] - rect[1]) * fraction
dpg.draw_line(
(rect[0] - 4, y),
(rect[2] + 4, y),
parent=drawlist,
tag=tag,
color=marker.color,
thickness=marker.thickness,
)
else:
x = rect[0] + (rect[2] - rect[0]) * fraction
dpg.draw_line(
(x, rect[1] - 4),
(x, rect[3] + 4),
parent=drawlist,
tag=tag,
color=marker.color,
thickness=marker.thickness,
)
def _draw_status_light(self, width: int, height: int, style: GaugeStyle) -> None:
state = self.state
config = state.config
if not isinstance(config, StatusLightConfig):
return
drawlist = _drawlist_tag(state)
status = _resolve_status(config, style)
radius = max(min(width, height) * 0.18, 10)
center = (max(radius + 14, 28), height * 0.5)
light = self.layers.dynamic.add(f"{state.tag}__status_light")
dpg.draw_circle(
center,
radius,
parent=drawlist,
tag=light,
color=style.border_color,
fill=status.color,
thickness=2,
)
text = status.text or str(config.state)
if config.show_label and config.label:
text = f"{config.label}: {text}"
text_tag = self.layers.text.add(f"{state.tag}__status_text")
dpg.draw_text(
(center[0] + radius + 14, max(center[1] - 9, 8)),
text,
parent=drawlist,
tag=text_tag,
color=status.value_color or style.text_color,
size=18,
)
def _display_text(self) -> str:
state = self.state
if not state.config.show_value:
@@ -164,3 +343,40 @@ def _smoothing_config(state: GaugeState) -> SmoothingConfig | None:
if isinstance(smoothing, bool):
return SmoothingConfig(enabled=smoothing)
return smoothing
def _drawlist_tag(state: GaugeState) -> Tag:
return cast(Tag, state.drawlist_tag)
def _bar_rect(width: int, height: int, orientation: str) -> Rect:
if orientation == "vertical":
top = 34.0
bottom = max(height - 34.0, top + 1.0)
bar_width = min(max(width * 0.34, 28.0), 56.0)
left = (width - bar_width) / 2.0
return left, top, left + bar_width, bottom
top = 36.0
bottom = max(height - 32.0, top + 1.0)
return 12.0, top, max(width - 12.0, 13.0), bottom
def _oriented_rect(rect: Rect, start: float, end: float, orientation: str) -> Rect:
start = min(max(start, 0.0), 1.0)
end = min(max(end, 0.0), 1.0)
if orientation == "vertical":
height = rect[3] - rect[1]
return rect[0], rect[3] - height * end, rect[2], rect[3] - height * start
width = rect[2] - rect[0]
return rect[0] + width * start, rect[1], rect[0] + width * end, rect[3]
def _resolve_status(config: StatusLightConfig, style: GaugeStyle) -> StatusState:
if config.states is not None and config.state in config.states:
return config.states[config.state]
if isinstance(config.state, bool):
return StatusState(
style.value_color if config.state else style.frame_color,
"ON" if config.state else "OFF",
)
return StatusState(style.value_color, str(config.state))

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import math
from collections.abc import Sequence
from typing import Literal
from .exceptions import GaugeConfigError, GaugeValueError
from .types import GaugeMarker, ThresholdBand
@@ -59,6 +60,29 @@ def fill_fraction(value: float, min_value: float, max_value: float, *, clamp: bo
return normalize_value(value, min_value, max_value, clamp=clamp)
def fill_span(
value: float,
min_value: float,
max_value: float,
*,
fill_mode: Literal["clamp", "centered"] = "clamp",
clamp: bool = True,
) -> tuple[float, float]:
fraction = fill_fraction(value, min_value, max_value, clamp=clamp)
if fill_mode == "clamp":
return 0.0, fraction
zero = fill_fraction(0.0, min_value, max_value, clamp=True)
return min(zero, fraction), max(zero, fraction)
def normalized_span(
start: float, end: float, min_value: float, max_value: float
) -> tuple[float, float]:
first = fill_fraction(start, min_value, max_value, clamp=True)
second = fill_fraction(end, min_value, max_value, clamp=True)
return min(first, second), max(first, second)
def generate_ticks(min_value: float, max_value: float, count: int) -> list[float]:
validate_range(min_value, max_value)
if count < 2:

View File

@@ -244,6 +244,8 @@ def update_runtime_gauge(tag: Tag, **config: Any) -> None:
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 ("state", "states")):
dirty |= DirtyFlags.DYNAMIC | DirtyFlags.TEXT
if any(
name in config for name in ("label", "unit", "precision", "show_value", "show_unit")
):

View File

@@ -132,6 +132,8 @@ def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[Confi
)
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)
@@ -225,3 +227,11 @@ def _add_tooltip(parent: Tag, tooltip: str | None) -> None:
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)

View File

@@ -0,0 +1,88 @@
# pyright: reportGeneralTypeIssues=false
from collections.abc import Generator
import dearpygui.dearpygui as dpg
import pytest
import dpg_gauges as dpgg
from dpg_gauges.gauges import LevelGaugeConfig, StatusLightConfig
from dpg_gauges.scales import fill_fraction, fill_span, normalized_span
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_bar_geometry_helpers_clamp_and_center() -> None:
assert fill_fraction(150, 0, 100, clamp=True) == 1.0
assert fill_span(25, 0, 100) == (0.0, 0.25)
assert fill_span(-25, -100, 100, fill_mode="centered") == (0.375, 0.5)
assert fill_span(25, -100, 100, fill_mode="centered") == (0.5, 0.625)
assert normalized_span(90, 10, 0, 100) == (0.1, 0.9)
def test_bar_runtime_renders_fill_thresholds_and_markers(dpg_context: None) -> None:
threshold = dpgg.ThresholdBand(35, 50, (255, 120, 0, 120))
marker = dpgg.GaugeMarker(50, (255, 255, 255, 255))
with dpg.window(label="Bar"):
dpgg.bar_gauge(
tag="boost",
label="Boost",
value=25,
min_value=0,
max_value=50,
width=240,
height=90,
thresholds=[threshold],
markers=[marker],
)
debug = dpgg.get_gauge_debug_state("boost")
assert "boost__bar_fill" in debug["renderer"]["layers"]["dynamic"]
assert "boost__threshold_0" in debug["renderer"]["layers"]["thresholds"]
assert "boost__marker_0" in debug["renderer"]["layers"]["markers"]
dpgg.set_value("boost", 100)
state = get_gauge_state("boost")
assert state.clamped_value == 50
assert state.displayed_value == 50
def test_level_runtime_uses_vertical_fill(dpg_context: None) -> None:
with dpg.window(label="Level"):
dpgg.level_gauge(tag="fuel", label="Fuel", value=75, width=120, height=180)
debug = dpgg.get_gauge_debug_state("fuel")
config = get_gauge_state("fuel").config
assert isinstance(config, LevelGaugeConfig)
assert config.orientation == "vertical"
assert "fuel__bar_fill" in debug["renderer"]["layers"]["dynamic"]
def test_status_light_uses_configured_state_text_and_color(dpg_context: None) -> None:
states = {
"ok": dpgg.StatusState((0, 220, 120, 255), "OK"),
"fault": dpgg.StatusState((255, 45, 45, 255), "FAULT"),
}
with dpg.window(label="Status"):
dpgg.status_light(tag="ecu", label="ECU", state="ok", states=states, width=180, height=70)
debug = dpgg.get_gauge_debug_state("ecu")
assert "ecu__status_light" in debug["renderer"]["layers"]["dynamic"]
assert "ecu__status_text" in debug["renderer"]["layers"]["text"]
dpgg.configure_gauge("ecu", state="fault")
config = get_gauge_state("ecu").config
assert isinstance(config, StatusLightConfig)
assert config.state == "fault"
assert get_gauge_state("ecu").dirty == 0