step 4: add digital bar level and status gauges
This commit is contained in:
10
README.md
10
README.md
@@ -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.
|
||||
|
||||
@@ -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
33
examples/basic_bar.py
Normal 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
30
examples/basic_level.py
Normal 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
35
examples/basic_status.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:
|
||||
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()
|
||||
37
examples/thresholds_markers.py
Normal file
37
examples/thresholds_markers.py
Normal 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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
|
||||
88
tests/test_step4_simple_gauges.py
Normal file
88
tests/test_step4_simple_gauges.py
Normal 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
|
||||
Reference in New Issue
Block a user