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.
|
Dear PyGui gauge widgets for dashboards and telemetry displays.
|
||||||
|
|
||||||
Current implementation status: Step 3 widget shells are implemented. Gauge creation functions still
|
Current implementation status: Step 4 simple gauge rendering is implemented. Digital, bar, level,
|
||||||
register pure gauge state without requiring a Dear PyGui context, and when a Dear PyGui context is
|
and status gauges render Dear PyGui drawlist visuals; bar and level gauges draw threshold bands and
|
||||||
active they create a group plus drawlist shell with placeholder frame and value text rendering.
|
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
|
Runtime value updates are intended for the Dear PyGui GUI thread. Attached gauges redraw immediately
|
||||||
for simple value/configuration changes.
|
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
|
## 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
|
## Completed steps
|
||||||
|
|
||||||
@@ -30,6 +30,15 @@ Step 1 - Public API contract and pure core:
|
|||||||
|
|
||||||
Step 4 - Digital, bar, level, and status gauges.
|
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:
|
Step 3 - Widget shell and renderer foundation:
|
||||||
|
|
||||||
Implemented during Step 3:
|
Implemented during Step 3:
|
||||||
@@ -69,7 +78,7 @@ Implemented during Step 2:
|
|||||||
|
|
||||||
## Known issues
|
## 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
|
## 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 .`.
|
||||||
- Ran `uv run ruff format --check .`.
|
- Ran `uv run ruff format --check .`.
|
||||||
- Ran `uv run pyright`.
|
- 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
|
## 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
import dearpygui.dearpygui as dpg
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
from .animation import advance_animation
|
from .animation import advance_animation
|
||||||
from .draw_layers import DrawLayers
|
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 .sizing import effective_size, update_measured_size
|
||||||
from .smoothing import advance_smoothing
|
from .smoothing import advance_smoothing
|
||||||
from .state import DirtyFlags, GaugeState, animation_config, size_state_for_gauge, update_state_size
|
from .state import DirtyFlags, GaugeState, animation_config, size_state_for_gauge, update_state_size
|
||||||
from .themes import GaugeStyle
|
from .themes import GaugeStyle
|
||||||
from .types import SmoothingConfig
|
from .types import SmoothingConfig, StatusState, Tag
|
||||||
|
|
||||||
|
Rect = tuple[float, float, float, float]
|
||||||
|
|
||||||
|
|
||||||
class GaugeRenderer:
|
class GaugeRenderer:
|
||||||
@@ -29,7 +32,7 @@ class GaugeRenderer:
|
|||||||
state = self.state
|
state = self.state
|
||||||
self._measure()
|
self._measure()
|
||||||
self._advance_values()
|
self._advance_values()
|
||||||
self._redraw_placeholder()
|
self._redraw()
|
||||||
state.dirty = DirtyFlags.NONE
|
state.dirty = DirtyFlags.NONE
|
||||||
if state.animation_state.active or state.smoothing_state.active:
|
if state.animation_state.active or state.smoothing_state.active:
|
||||||
self.schedule_frame()
|
self.schedule_frame()
|
||||||
@@ -63,7 +66,6 @@ class GaugeRenderer:
|
|||||||
state = self.state
|
state = self.state
|
||||||
if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag):
|
if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag):
|
||||||
return
|
return
|
||||||
|
|
||||||
width = dpg.get_item_width(state.drawlist_tag) or 0
|
width = dpg.get_item_width(state.drawlist_tag) or 0
|
||||||
height = dpg.get_item_height(state.drawlist_tag) or 0
|
height = dpg.get_item_height(state.drawlist_tag) or 0
|
||||||
size = size_state_for_gauge(state)
|
size = size_state_for_gauge(state)
|
||||||
@@ -83,7 +85,7 @@ class GaugeRenderer:
|
|||||||
state.smoothing_state, state.clamped_value, smoothing
|
state.smoothing_state, state.clamped_value, smoothing
|
||||||
)
|
)
|
||||||
|
|
||||||
def _redraw_placeholder(self) -> None:
|
def _redraw(self) -> None:
|
||||||
state = self.state
|
state = self.state
|
||||||
if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag):
|
if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag):
|
||||||
return
|
return
|
||||||
@@ -97,12 +99,25 @@ class GaugeRenderer:
|
|||||||
self.layers.clear()
|
self.layers.clear()
|
||||||
|
|
||||||
style = state.config.style or GaugeStyle()
|
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")
|
background = self.layers.background.add(f"{state.tag}__background")
|
||||||
frame = self.layers.static.add(f"{state.tag}__frame")
|
frame = self.layers.static.add(f"{state.tag}__frame")
|
||||||
dpg.draw_rectangle(
|
dpg.draw_rectangle(
|
||||||
(0, 0),
|
(0, 0),
|
||||||
(width, height),
|
(width, height),
|
||||||
parent=state.drawlist_tag,
|
parent=drawlist,
|
||||||
tag=background,
|
tag=background,
|
||||||
color=style.border_color,
|
color=style.border_color,
|
||||||
fill=style.background_color,
|
fill=style.background_color,
|
||||||
@@ -112,36 +127,200 @@ class GaugeRenderer:
|
|||||||
dpg.draw_rectangle(
|
dpg.draw_rectangle(
|
||||||
(4, 4),
|
(4, 4),
|
||||||
(max(width - 4, 4), max(height - 4, 4)),
|
(max(width - 4, 4), max(height - 4, 4)),
|
||||||
parent=state.drawlist_tag,
|
parent=drawlist,
|
||||||
tag=frame,
|
tag=frame,
|
||||||
color=style.frame_color,
|
color=style.frame_color,
|
||||||
rounding=4,
|
rounding=4,
|
||||||
thickness=1,
|
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:
|
if state.config.show_label and state.config.label:
|
||||||
|
drawlist = _drawlist_tag(state)
|
||||||
label = self.layers.text.add(f"{state.tag}__label")
|
label = self.layers.text.add(f"{state.tag}__label")
|
||||||
dpg.draw_text(
|
dpg.draw_text(
|
||||||
(12, label_y),
|
(x, y),
|
||||||
state.config.label,
|
state.config.label,
|
||||||
parent=state.drawlist_tag,
|
parent=drawlist,
|
||||||
tag=label,
|
tag=label,
|
||||||
color=style.muted_text_color,
|
color=style.muted_text_color,
|
||||||
size=14,
|
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")
|
value_tag = self.layers.dynamic.add(f"{state.tag}__value")
|
||||||
dpg.draw_text(
|
dpg.draw_text(
|
||||||
(12, max(height * 0.45 - 10, 24)),
|
(12, max(height * 0.45 - 10, 24)),
|
||||||
value,
|
self._display_text(),
|
||||||
parent=state.drawlist_tag,
|
parent=drawlist,
|
||||||
tag=value_tag,
|
tag=value_tag,
|
||||||
color=style.value_color if not state.invalid_value else style.redline_color,
|
color=style.value_color if not state.invalid_value else style.redline_color,
|
||||||
size=24,
|
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:
|
def _display_text(self) -> str:
|
||||||
state = self.state
|
state = self.state
|
||||||
if not state.config.show_value:
|
if not state.config.show_value:
|
||||||
@@ -164,3 +343,40 @@ def _smoothing_config(state: GaugeState) -> SmoothingConfig | None:
|
|||||||
if isinstance(smoothing, bool):
|
if isinstance(smoothing, bool):
|
||||||
return SmoothingConfig(enabled=smoothing)
|
return SmoothingConfig(enabled=smoothing)
|
||||||
return 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
|
import math
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from .exceptions import GaugeConfigError, GaugeValueError
|
from .exceptions import GaugeConfigError, GaugeValueError
|
||||||
from .types import GaugeMarker, ThresholdBand
|
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)
|
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]:
|
def generate_ticks(min_value: float, max_value: float, count: int) -> list[float]:
|
||||||
validate_range(min_value, max_value)
|
validate_range(min_value, max_value)
|
||||||
if count < 2:
|
if count < 2:
|
||||||
|
|||||||
@@ -244,6 +244,8 @@ def update_runtime_gauge(tag: Tag, **config: Any) -> None:
|
|||||||
dirty |= DirtyFlags.STATIC | DirtyFlags.DYNAMIC | DirtyFlags.TEXT
|
dirty |= DirtyFlags.STATIC | DirtyFlags.DYNAMIC | DirtyFlags.TEXT
|
||||||
if any(name in config for name in ("thresholds", "markers")):
|
if any(name in config for name in ("thresholds", "markers")):
|
||||||
dirty |= DirtyFlags.STATIC | DirtyFlags.DYNAMIC
|
dirty |= DirtyFlags.STATIC | DirtyFlags.DYNAMIC
|
||||||
|
if any(name in config for name in ("state", "states")):
|
||||||
|
dirty |= DirtyFlags.DYNAMIC | DirtyFlags.TEXT
|
||||||
if any(
|
if any(
|
||||||
name in config for name in ("label", "unit", "precision", "show_value", "show_unit")
|
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)
|
renderer = GaugeRenderer(state)
|
||||||
state.renderer = renderer
|
state.renderer = renderer
|
||||||
|
if config.callback is not None:
|
||||||
|
dpg.configure_item(drawlist, callback=_item_callback, user_data=state.tag)
|
||||||
renderer.render()
|
renderer.render()
|
||||||
return GaugeContext(state)
|
return GaugeContext(state)
|
||||||
|
|
||||||
@@ -225,3 +227,11 @@ def _add_tooltip(parent: Tag, tooltip: str | None) -> None:
|
|||||||
return
|
return
|
||||||
tooltip_tag = dpg.add_tooltip(parent)
|
tooltip_tag = dpg.add_tooltip(parent)
|
||||||
dpg.add_text(tooltip, parent=tooltip_tag)
|
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