step 5: complete first gauge catalogue
This commit is contained in:
11
README.md
11
README.md
@@ -2,13 +2,10 @@
|
|||||||
|
|
||||||
Dear PyGui gauge widgets for dashboards and telemetry displays.
|
Dear PyGui gauge widgets for dashboards and telemetry displays.
|
||||||
|
|
||||||
Current implementation status: Step 4 simple gauge rendering is implemented. Digital, bar, level,
|
Current implementation status: Step 5 first catalogue rendering is implemented. Analog, digital,
|
||||||
and status gauges render Dear PyGui drawlist visuals; bar and level gauges draw threshold bands and
|
bar, level, status, segmented, compass, thermometer, battery, and multi-value gauges render Dear
|
||||||
markers. Gauge creation functions still register pure gauge state without requiring a Dear PyGui
|
PyGui drawlist visuals. Bar/level gauges draw threshold bands and markers; analog gauges draw ticks,
|
||||||
context, and when a Dear PyGui context is active they create a group plus drawlist shell.
|
redline bands, markers, and needles.
|
||||||
|
|
||||||
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 4 completed and verified with automated checks. Step 4 commit is pending.
|
Step 5 completed and verified with automated checks. Step 5 commit is pending.
|
||||||
|
|
||||||
## Completed steps
|
## Completed steps
|
||||||
|
|
||||||
@@ -28,7 +28,20 @@ Step 1 - Public API contract and pure core:
|
|||||||
|
|
||||||
## Current step
|
## Current step
|
||||||
|
|
||||||
Step 4 - Digital, bar, level, and status gauges.
|
Step 6 - High-rate updates, animation, and stress examples.
|
||||||
|
|
||||||
|
Step 5 - Analog, segmented, compass, thermometer, battery, and multi-value gauges:
|
||||||
|
|
||||||
|
Implemented during Step 5:
|
||||||
|
|
||||||
|
- Added analog and compass dial rendering with arcs, ticks, tick labels, needles, redline bands, and markers.
|
||||||
|
- Added compass heading wraparound for runtime values.
|
||||||
|
- Added segmented gauge rendering with active segment calculation.
|
||||||
|
- Added thermometer and battery-specific drawlist rendering.
|
||||||
|
- Added multi-value gauge rendering.
|
||||||
|
- Added Step 5 examples and tests for angle math, tick generation, compass wraparound, segment activation, battery clamp, and multi-value rendering.
|
||||||
|
|
||||||
|
Step 4 - Digital, bar, level, and status gauges:
|
||||||
|
|
||||||
Implemented during Step 4:
|
Implemented during Step 4:
|
||||||
|
|
||||||
@@ -78,7 +91,7 @@ Implemented during Step 2:
|
|||||||
|
|
||||||
## Known issues
|
## Known issues
|
||||||
|
|
||||||
Manual visual example checks for Steps 3 and 4 were not run in this headless session.
|
Manual visual example checks for Steps 3, 4, and 5 were not run in this headless session.
|
||||||
|
|
||||||
## Commands used
|
## Commands used
|
||||||
|
|
||||||
@@ -97,6 +110,14 @@ Manual visual example checks for Steps 3 and 4 were not run in this headless ses
|
|||||||
- Ran `uv run ruff check .`.
|
- Ran `uv run ruff check .`.
|
||||||
- Ran `uv run ruff format --check .`.
|
- Ran `uv run ruff format --check .`.
|
||||||
- Ran `uv run pyright`.
|
- Ran `uv run pyright`.
|
||||||
|
- Committed Step 4 with `git commit -m "step 4: add digital bar level and status gauges"` before starting Step 5.
|
||||||
|
- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 5 changes.
|
||||||
|
- Implemented Step 5 first catalogue rendering, compass wraparound, examples, README status update, and tests.
|
||||||
|
- Ran `uv run pytest`.
|
||||||
|
- Ran `uv run ruff check .`.
|
||||||
|
- Ran `uv run ruff format .`.
|
||||||
|
- Ran `uv run ruff format --check .`.
|
||||||
|
- Ran `uv run pyright`.
|
||||||
- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 2 changes.
|
- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 2 changes.
|
||||||
- Implemented Step 2 state, sizing, animation, smoothing, runtime API, diagnostics, tests, and README status update.
|
- Implemented Step 2 state, sizing, animation, smoothing, runtime API, diagnostics, tests, and README status update.
|
||||||
- Ran `uv run pytest`.
|
- Ran `uv run pytest`.
|
||||||
@@ -121,4 +142,4 @@ Manual visual example checks for Steps 3 and 4 were not run in this headless ses
|
|||||||
|
|
||||||
## Next action
|
## Next action
|
||||||
|
|
||||||
Commit Step 4, then implement Step 5.
|
Commit Step 5, then implement Step 6.
|
||||||
|
|||||||
25
examples/basic_battery.py
Normal file
25
examples/basic_battery.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="Battery Gauge", width=320, height=180):
|
||||||
|
dpgg.battery_gauge(
|
||||||
|
tag="battery", label="Battery", value=82, unit="%", width=260, height=110
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges battery", width=360, height=220)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
23
examples/basic_compass.py
Normal file
23
examples/basic_compass.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
with dpg.window(label="Compass Gauge", width=320, height=320):
|
||||||
|
dpgg.compass_gauge(tag="heading", label="Heading", value=275, width=240, height=240)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges compass", width=360, height=360)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
29
examples/basic_multi_value.py
Normal file
29
examples/basic_multi_value.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
with dpg.window(label="Multi Value Gauge", width=340, height=220):
|
||||||
|
dpgg.multi_value_gauge(
|
||||||
|
tag="engine",
|
||||||
|
label="Engine",
|
||||||
|
values={"AFR": 12.8, "Oil": "58 psi", "Fuel": "43 psi"},
|
||||||
|
width=280,
|
||||||
|
height=150,
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges multi-value", width=380, height=260)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
32
examples/basic_segmented.py
Normal file
32
examples/basic_segmented.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
with dpg.window(label="Segmented Gauge", width=360, height=160):
|
||||||
|
dpgg.segmented_gauge(
|
||||||
|
tag="shift",
|
||||||
|
label="Shift lights",
|
||||||
|
value=7200,
|
||||||
|
min_value=0,
|
||||||
|
max_value=8000,
|
||||||
|
segments=12,
|
||||||
|
width=300,
|
||||||
|
height=90,
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges segmented", width=400, height=200)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
32
examples/basic_thermometer.py
Normal file
32
examples/basic_thermometer.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dpg.create_context()
|
||||||
|
try:
|
||||||
|
with dpg.window(label="Thermometer Gauge", width=280, height=340):
|
||||||
|
dpgg.thermometer_gauge(
|
||||||
|
tag="coolant",
|
||||||
|
label="Coolant",
|
||||||
|
value=92,
|
||||||
|
min_value=40,
|
||||||
|
max_value=120,
|
||||||
|
unit="C",
|
||||||
|
width=180,
|
||||||
|
height=260,
|
||||||
|
)
|
||||||
|
|
||||||
|
dpg.create_viewport(title="dpg-gauges thermometer", width=320, height=380)
|
||||||
|
dpg.setup_dearpygui()
|
||||||
|
dpg.show_viewport()
|
||||||
|
dpg.start_dearpygui()
|
||||||
|
finally:
|
||||||
|
dpg.destroy_context()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
@@ -9,9 +10,26 @@ 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 .gauges import BarGaugeConfig, LevelGaugeConfig, StatusLightConfig
|
from .gauges import (
|
||||||
from .scales import fill_fraction, fill_span, format_gauge_value, normalized_span
|
AnalogGaugeConfig,
|
||||||
from .sizing import effective_size, update_measured_size
|
BarGaugeConfig,
|
||||||
|
BatteryGaugeConfig,
|
||||||
|
CompassGaugeConfig,
|
||||||
|
LevelGaugeConfig,
|
||||||
|
MultiValueGaugeConfig,
|
||||||
|
SegmentedGaugeConfig,
|
||||||
|
StatusLightConfig,
|
||||||
|
)
|
||||||
|
from .scales import (
|
||||||
|
active_segments,
|
||||||
|
fill_fraction,
|
||||||
|
fill_span,
|
||||||
|
format_gauge_value,
|
||||||
|
generate_ticks,
|
||||||
|
normalized_span,
|
||||||
|
value_to_angle,
|
||||||
|
)
|
||||||
|
from .sizing import effective_size, square_gauge_rect, 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
|
||||||
@@ -102,8 +120,18 @@ class GaugeRenderer:
|
|||||||
self._draw_frame(width, height, style)
|
self._draw_frame(width, height, style)
|
||||||
if state.gauge_type == "digital":
|
if state.gauge_type == "digital":
|
||||||
self._draw_digital(width, height, style)
|
self._draw_digital(width, height, style)
|
||||||
|
elif state.gauge_type in {"analog", "compass"}:
|
||||||
|
self._draw_analog(width, height, style)
|
||||||
elif state.gauge_type in {"bar", "level"}:
|
elif state.gauge_type in {"bar", "level"}:
|
||||||
self._draw_bar_or_level(width, height, style)
|
self._draw_bar_or_level(width, height, style)
|
||||||
|
elif state.gauge_type == "segmented":
|
||||||
|
self._draw_segmented(width, height, style)
|
||||||
|
elif state.gauge_type == "thermometer":
|
||||||
|
self._draw_thermometer(width, height, style)
|
||||||
|
elif state.gauge_type == "battery":
|
||||||
|
self._draw_battery(width, height, style)
|
||||||
|
elif state.gauge_type == "multi_value":
|
||||||
|
self._draw_multi_value(width, height, style)
|
||||||
elif state.gauge_type == "status_light":
|
elif state.gauge_type == "status_light":
|
||||||
self._draw_status_light(width, height, style)
|
self._draw_status_light(width, height, style)
|
||||||
else:
|
else:
|
||||||
@@ -321,6 +349,280 @@ class GaugeRenderer:
|
|||||||
size=18,
|
size=18,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _draw_analog(self, width: int, height: int, style: GaugeStyle) -> None:
|
||||||
|
state = self.state
|
||||||
|
config = state.config
|
||||||
|
if not isinstance(config, AnalogGaugeConfig | CompassGaugeConfig):
|
||||||
|
return
|
||||||
|
drawlist = _drawlist_tag(state)
|
||||||
|
rect = square_gauge_rect(width - 16, height - 16)
|
||||||
|
cx = 8 + rect.x + rect.width / 2.0
|
||||||
|
cy = 8 + rect.y + rect.height / 2.0
|
||||||
|
radius = max(min(rect.width, rect.height) * 0.43, 12.0)
|
||||||
|
if config.show_label and config.label:
|
||||||
|
self._draw_label(style, x=12, y=10)
|
||||||
|
self._draw_arc(
|
||||||
|
(cx, cy),
|
||||||
|
radius,
|
||||||
|
config.start_angle,
|
||||||
|
config.end_angle,
|
||||||
|
style.frame_color,
|
||||||
|
config.arc_width,
|
||||||
|
)
|
||||||
|
for threshold in config.thresholds:
|
||||||
|
start = value_to_angle(
|
||||||
|
threshold.start,
|
||||||
|
config.min_value,
|
||||||
|
config.max_value,
|
||||||
|
config.start_angle,
|
||||||
|
config.end_angle,
|
||||||
|
)
|
||||||
|
end = value_to_angle(
|
||||||
|
threshold.end,
|
||||||
|
config.min_value,
|
||||||
|
config.max_value,
|
||||||
|
config.start_angle,
|
||||||
|
config.end_angle,
|
||||||
|
)
|
||||||
|
self._draw_arc((cx, cy), radius, start, end, threshold.color, config.arc_width + 2)
|
||||||
|
if config.redline_start is not None:
|
||||||
|
start = value_to_angle(
|
||||||
|
config.redline_start,
|
||||||
|
config.min_value,
|
||||||
|
config.max_value,
|
||||||
|
config.start_angle,
|
||||||
|
config.end_angle,
|
||||||
|
)
|
||||||
|
self._draw_arc(
|
||||||
|
(cx, cy), radius, start, config.end_angle, style.redline_color, config.arc_width + 3
|
||||||
|
)
|
||||||
|
ticks = generate_ticks(config.min_value, config.max_value, max(config.major_ticks, 2))
|
||||||
|
for index, tick in enumerate(ticks):
|
||||||
|
angle = value_to_angle(
|
||||||
|
tick, config.min_value, config.max_value, config.start_angle, config.end_angle
|
||||||
|
)
|
||||||
|
outer = _point_on_circle(cx, cy, radius + 4, angle)
|
||||||
|
inner = _point_on_circle(cx, cy, radius - 10, angle)
|
||||||
|
tag = self.layers.static.add(f"{state.tag}__tick_{index}")
|
||||||
|
dpg.draw_line(
|
||||||
|
outer, inner, parent=drawlist, tag=tag, color=style.tick_color, thickness=2
|
||||||
|
)
|
||||||
|
if config.show_tick_labels:
|
||||||
|
label_pos = _point_on_circle(cx, cy, radius - 26, angle)
|
||||||
|
label = self.layers.text.add(f"{state.tag}__tick_label_{index}")
|
||||||
|
text = f"{tick:.0f}"
|
||||||
|
dpg.draw_text(
|
||||||
|
(label_pos[0] - 10, label_pos[1] - 6),
|
||||||
|
text,
|
||||||
|
parent=drawlist,
|
||||||
|
tag=label,
|
||||||
|
color=style.muted_text_color,
|
||||||
|
size=10,
|
||||||
|
)
|
||||||
|
for index, marker in enumerate(config.markers):
|
||||||
|
angle = value_to_angle(
|
||||||
|
marker.value,
|
||||||
|
config.min_value,
|
||||||
|
config.max_value,
|
||||||
|
config.start_angle,
|
||||||
|
config.end_angle,
|
||||||
|
)
|
||||||
|
outer = _point_on_circle(cx, cy, radius + 8, angle)
|
||||||
|
inner = _point_on_circle(cx, cy, radius - 18, angle)
|
||||||
|
tag = self.layers.markers.add(f"{state.tag}__marker_{index}")
|
||||||
|
dpg.draw_line(
|
||||||
|
outer,
|
||||||
|
inner,
|
||||||
|
parent=drawlist,
|
||||||
|
tag=tag,
|
||||||
|
color=marker.color,
|
||||||
|
thickness=marker.thickness,
|
||||||
|
)
|
||||||
|
if state.displayed_value is not None and not state.invalid_value:
|
||||||
|
angle = value_to_angle(
|
||||||
|
state.displayed_value,
|
||||||
|
config.min_value,
|
||||||
|
config.max_value,
|
||||||
|
config.start_angle,
|
||||||
|
config.end_angle,
|
||||||
|
)
|
||||||
|
end = _point_on_circle(cx, cy, radius * config.needle_length, angle)
|
||||||
|
needle = self.layers.dynamic.add(f"{state.tag}__needle")
|
||||||
|
dpg.draw_line(
|
||||||
|
(cx, cy),
|
||||||
|
end,
|
||||||
|
parent=drawlist,
|
||||||
|
tag=needle,
|
||||||
|
color=style.needle_color,
|
||||||
|
thickness=config.needle_width,
|
||||||
|
)
|
||||||
|
cap = self.layers.dynamic.add(f"{state.tag}__center_cap")
|
||||||
|
dpg.draw_circle(
|
||||||
|
(cx, cy),
|
||||||
|
config.center_cap_radius,
|
||||||
|
parent=drawlist,
|
||||||
|
tag=cap,
|
||||||
|
color=style.border_color,
|
||||||
|
fill=style.value_color,
|
||||||
|
)
|
||||||
|
if config.show_value:
|
||||||
|
value = self.layers.text.add(f"{state.tag}__value")
|
||||||
|
dpg.draw_text(
|
||||||
|
(cx - radius * 0.45, cy + radius * 0.38),
|
||||||
|
self._display_text(),
|
||||||
|
parent=drawlist,
|
||||||
|
tag=value,
|
||||||
|
color=style.text_color,
|
||||||
|
size=14,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_arc(
|
||||||
|
self,
|
||||||
|
center: tuple[float, float],
|
||||||
|
radius: float,
|
||||||
|
start_angle: float,
|
||||||
|
end_angle: float,
|
||||||
|
color: tuple[int, ...],
|
||||||
|
thickness: float,
|
||||||
|
) -> None:
|
||||||
|
drawlist = _drawlist_tag(self.state)
|
||||||
|
steps = max(int(abs(end_angle - start_angle) / 8), 2)
|
||||||
|
previous = _point_on_circle(center[0], center[1], radius, start_angle)
|
||||||
|
for index in range(1, steps + 1):
|
||||||
|
angle = start_angle + (end_angle - start_angle) * (index / steps)
|
||||||
|
current = _point_on_circle(center[0], center[1], radius, angle)
|
||||||
|
tag = self.layers.static.add(
|
||||||
|
f"{self.state.tag}__arc_{len(self.layers.static.item_tags)}"
|
||||||
|
)
|
||||||
|
dpg.draw_line(
|
||||||
|
previous, current, parent=drawlist, tag=tag, color=color, thickness=thickness
|
||||||
|
)
|
||||||
|
previous = current
|
||||||
|
|
||||||
|
def _draw_segmented(self, width: int, height: int, style: GaugeStyle) -> None:
|
||||||
|
state = self.state
|
||||||
|
config = state.config
|
||||||
|
if not isinstance(config, SegmentedGaugeConfig):
|
||||||
|
return
|
||||||
|
self._draw_label(style)
|
||||||
|
drawlist = _drawlist_tag(state)
|
||||||
|
count = config.segments
|
||||||
|
active = 0
|
||||||
|
if state.displayed_value is not None and not state.invalid_value:
|
||||||
|
active = active_segments(
|
||||||
|
state.displayed_value, config.min_value, config.max_value, count
|
||||||
|
)
|
||||||
|
gap = 4.0
|
||||||
|
left, top, right, bottom = 12.0, 38.0, max(width - 12.0, 13.0), max(height - 28.0, 39.0)
|
||||||
|
segment_width = max((right - left - gap * (count - 1)) / count, 1.0)
|
||||||
|
for index in range(count):
|
||||||
|
x1 = left + index * (segment_width + gap)
|
||||||
|
x2 = x1 + segment_width
|
||||||
|
color = style.value_color if index < active else style.frame_color
|
||||||
|
tag = self.layers.dynamic.add(f"{state.tag}__segment_{index}")
|
||||||
|
dpg.draw_rectangle(
|
||||||
|
(x1, top),
|
||||||
|
(x2, bottom),
|
||||||
|
parent=drawlist,
|
||||||
|
tag=tag,
|
||||||
|
color=style.border_color,
|
||||||
|
fill=color,
|
||||||
|
rounding=config.corner_radius,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_thermometer(self, width: int, height: int, style: GaugeStyle) -> None:
|
||||||
|
self._draw_bar_or_level(width, height, style)
|
||||||
|
drawlist = _drawlist_tag(self.state)
|
||||||
|
bulb = self.layers.dynamic.add(f"{self.state.tag}__bulb")
|
||||||
|
dpg.draw_circle(
|
||||||
|
(width / 2.0, max(height - 30.0, 18.0)),
|
||||||
|
16,
|
||||||
|
parent=drawlist,
|
||||||
|
tag=bulb,
|
||||||
|
color=style.border_color,
|
||||||
|
fill=style.value_color,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_battery(self, width: int, height: int, style: GaugeStyle) -> None:
|
||||||
|
state = self.state
|
||||||
|
config = state.config
|
||||||
|
if not isinstance(config, BatteryGaugeConfig):
|
||||||
|
return
|
||||||
|
self._draw_label(style)
|
||||||
|
drawlist = _drawlist_tag(state)
|
||||||
|
body = (18.0, 40.0, max(width - 34.0, 58.0), max(height - 26.0, 74.0))
|
||||||
|
nub = (
|
||||||
|
body[2],
|
||||||
|
body[1] + (body[3] - body[1]) * 0.33,
|
||||||
|
body[2] + 10,
|
||||||
|
body[1] + (body[3] - body[1]) * 0.67,
|
||||||
|
)
|
||||||
|
dpg.draw_rectangle(
|
||||||
|
(body[0], body[1]),
|
||||||
|
(body[2], body[3]),
|
||||||
|
parent=drawlist,
|
||||||
|
tag=self.layers.static.add(f"{state.tag}__battery_body"),
|
||||||
|
color=style.border_color,
|
||||||
|
thickness=2,
|
||||||
|
rounding=3,
|
||||||
|
)
|
||||||
|
dpg.draw_rectangle(
|
||||||
|
(nub[0], nub[1]),
|
||||||
|
(nub[2], nub[3]),
|
||||||
|
parent=drawlist,
|
||||||
|
tag=self.layers.static.add(f"{state.tag}__battery_nub"),
|
||||||
|
color=style.border_color,
|
||||||
|
fill=style.border_color,
|
||||||
|
rounding=2,
|
||||||
|
)
|
||||||
|
if state.displayed_value is not None and not state.invalid_value:
|
||||||
|
fraction = fill_fraction(
|
||||||
|
state.displayed_value, config.min_value, config.max_value, clamp=True
|
||||||
|
)
|
||||||
|
pad = 5.0
|
||||||
|
fill = (
|
||||||
|
body[0] + pad,
|
||||||
|
body[1] + pad,
|
||||||
|
body[0] + pad + (body[2] - body[0] - pad * 2) * fraction,
|
||||||
|
body[3] - pad,
|
||||||
|
)
|
||||||
|
if fill[2] > fill[0]:
|
||||||
|
dpg.draw_rectangle(
|
||||||
|
(fill[0], fill[1]),
|
||||||
|
(fill[2], fill[3]),
|
||||||
|
parent=drawlist,
|
||||||
|
tag=self.layers.dynamic.add(f"{state.tag}__battery_fill"),
|
||||||
|
color=style.value_color,
|
||||||
|
fill=style.value_color,
|
||||||
|
rounding=2,
|
||||||
|
)
|
||||||
|
if config.show_value:
|
||||||
|
dpg.draw_text(
|
||||||
|
(18, height - 22),
|
||||||
|
self._display_text(),
|
||||||
|
parent=drawlist,
|
||||||
|
tag=self.layers.text.add(f"{state.tag}__value"),
|
||||||
|
color=style.text_color,
|
||||||
|
size=14,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_multi_value(self, _width: int, height: int, style: GaugeStyle) -> None:
|
||||||
|
state = self.state
|
||||||
|
config = state.config
|
||||||
|
if not isinstance(config, MultiValueGaugeConfig):
|
||||||
|
return
|
||||||
|
self._draw_label(style)
|
||||||
|
drawlist = _drawlist_tag(state)
|
||||||
|
values = config.values or {}
|
||||||
|
y = 34.0
|
||||||
|
for index, (name, value) in enumerate(values.items()):
|
||||||
|
text = f"{name}: {value}"
|
||||||
|
tag = self.layers.text.add(f"{state.tag}__multi_{index}")
|
||||||
|
dpg.draw_text((12, y), text, parent=drawlist, tag=tag, color=style.text_color, size=16)
|
||||||
|
y += 22.0
|
||||||
|
if y > height - 18:
|
||||||
|
break
|
||||||
|
|
||||||
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:
|
||||||
@@ -371,6 +673,13 @@ def _oriented_rect(rect: Rect, start: float, end: float, orientation: str) -> Re
|
|||||||
return rect[0] + width * start, rect[1], rect[0] + width * end, rect[3]
|
return rect[0] + width * start, rect[1], rect[0] + width * end, rect[3]
|
||||||
|
|
||||||
|
|
||||||
|
def _point_on_circle(
|
||||||
|
cx: float, cy: float, radius: float, angle_degrees: float
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
radians = math.radians(angle_degrees)
|
||||||
|
return cx + math.cos(radians) * radius, cy - math.sin(radians) * radius
|
||||||
|
|
||||||
|
|
||||||
def _resolve_status(config: StatusLightConfig, style: GaugeStyle) -> StatusState:
|
def _resolve_status(config: StatusLightConfig, style: GaugeStyle) -> StatusState:
|
||||||
if config.states is not None and config.state in config.states:
|
if config.states is not None and config.state in config.states:
|
||||||
return config.states[config.state]
|
return config.states[config.state]
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ def value_to_angle(
|
|||||||
return start_angle + (end_angle - start_angle) * fraction
|
return start_angle + (end_angle - start_angle) * fraction
|
||||||
|
|
||||||
|
|
||||||
|
def compass_heading(value: float) -> float:
|
||||||
|
if not math.isfinite(value):
|
||||||
|
msg = "heading value must be finite"
|
||||||
|
raise GaugeValueError(msg)
|
||||||
|
return value % 360.0
|
||||||
|
|
||||||
|
|
||||||
def fill_fraction(value: float, min_value: float, max_value: float, *, clamp: bool = True) -> float:
|
def fill_fraction(value: float, min_value: float, max_value: float, *, clamp: bool = True) -> float:
|
||||||
return normalize_value(value, min_value, max_value, clamp=clamp)
|
return normalize_value(value, min_value, max_value, clamp=clamp)
|
||||||
|
|
||||||
@@ -83,6 +90,16 @@ def normalized_span(
|
|||||||
return min(first, second), max(first, second)
|
return min(first, second), max(first, second)
|
||||||
|
|
||||||
|
|
||||||
|
def active_segments(
|
||||||
|
value: float, min_value: float, max_value: float, segments: int, *, clamp: bool = True
|
||||||
|
) -> int:
|
||||||
|
if segments <= 0:
|
||||||
|
msg = "segments must be greater than zero"
|
||||||
|
raise GaugeConfigError(msg)
|
||||||
|
fraction = fill_fraction(value, min_value, max_value, clamp=clamp)
|
||||||
|
return min(max(math.ceil(fraction * segments), 0), segments)
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ from typing import Any
|
|||||||
|
|
||||||
from .animation import AnimationState, start_animation
|
from .animation import AnimationState, start_animation
|
||||||
from .exceptions import GaugeConfigError, GaugeNotFoundError
|
from .exceptions import GaugeConfigError, GaugeNotFoundError
|
||||||
from .gauges import GaugeConfig
|
from .gauges import CompassGaugeConfig, GaugeConfig
|
||||||
from .scales import clamp_value, validate_markers, validate_thresholds
|
from .scales import clamp_value, compass_heading, validate_markers, validate_thresholds
|
||||||
from .sizing import RequestedSize, SizeState
|
from .sizing import RequestedSize, SizeState
|
||||||
from .smoothing import SmoothingState
|
from .smoothing import SmoothingState
|
||||||
from .themes import GaugeTheme, get_theme
|
from .themes import GaugeTheme, get_theme
|
||||||
@@ -150,6 +150,8 @@ def clamp_runtime_value(value: GaugeValue, config: GaugeConfig) -> tuple[float |
|
|||||||
return None, value is not None
|
return None, value is not None
|
||||||
if not math.isfinite(numeric):
|
if not math.isfinite(numeric):
|
||||||
return None, True
|
return None, True
|
||||||
|
if isinstance(config, CompassGaugeConfig):
|
||||||
|
numeric = compass_heading(numeric)
|
||||||
if config.clamp:
|
if config.clamp:
|
||||||
return clamp_value(numeric, config.min_value, config.max_value), False
|
return clamp_value(numeric, config.min_value, config.max_value), False
|
||||||
return numeric, False
|
return numeric, False
|
||||||
|
|||||||
91
tests/test_step5_catalogue.py
Normal file
91
tests/test_step5_catalogue.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
import dearpygui.dearpygui as dpg
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import dpg_gauges as dpgg
|
||||||
|
from dpg_gauges.scales import active_segments, compass_heading, generate_ticks, value_to_angle
|
||||||
|
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_value_to_angle_ticks_and_compass_wrap() -> None:
|
||||||
|
assert value_to_angle(50, 0, 100, 225, -45) == 90
|
||||||
|
assert generate_ticks(0, 100, 3) == [0, 50, 100]
|
||||||
|
assert compass_heading(370) == 10
|
||||||
|
assert compass_heading(-10) == 350
|
||||||
|
|
||||||
|
|
||||||
|
def test_analog_renders_needle_redline_and_marker(dpg_context: None) -> None:
|
||||||
|
marker = dpgg.GaugeMarker(6000, (255, 255, 255, 255))
|
||||||
|
with dpg.window(label="Analog"):
|
||||||
|
dpgg.analog_gauge(
|
||||||
|
tag="rpm",
|
||||||
|
label="RPM",
|
||||||
|
value=3500,
|
||||||
|
max_value=8000,
|
||||||
|
redline_start=6500,
|
||||||
|
markers=[marker],
|
||||||
|
width=240,
|
||||||
|
height=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
debug = dpgg.get_gauge_debug_state("rpm")
|
||||||
|
assert "rpm__needle" in debug["renderer"]["layers"]["dynamic"]
|
||||||
|
assert "rpm__marker_0" in debug["renderer"]["layers"]["markers"]
|
||||||
|
assert get_gauge_state("rpm").measured_width == 240
|
||||||
|
|
||||||
|
|
||||||
|
def test_compass_wraps_runtime_heading(dpg_context: None) -> None:
|
||||||
|
with dpg.window(label="Compass"):
|
||||||
|
dpgg.compass_gauge(tag="heading", value=370, width=200, height=200)
|
||||||
|
|
||||||
|
state = get_gauge_state("heading")
|
||||||
|
assert state.clamped_value == 10
|
||||||
|
assert (
|
||||||
|
"heading__needle" in dpgg.get_gauge_debug_state("heading")["renderer"]["layers"]["dynamic"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_segmented_activation_and_rendering(dpg_context: None) -> None:
|
||||||
|
assert active_segments(55, 0, 100, 10) == 6
|
||||||
|
with dpg.window(label="Segments"):
|
||||||
|
dpgg.segmented_gauge(tag="shift", value=55, segments=10, width=260, height=80)
|
||||||
|
|
||||||
|
dynamic = dpgg.get_gauge_debug_state("shift")["renderer"]["layers"]["dynamic"]
|
||||||
|
assert "shift__segment_0" in dynamic
|
||||||
|
assert "shift__segment_9" in dynamic
|
||||||
|
|
||||||
|
|
||||||
|
def test_battery_clamps_and_multi_value_renders(dpg_context: None) -> None:
|
||||||
|
with dpg.window(label="Catalogue"):
|
||||||
|
dpgg.battery_gauge(tag="battery", value=125, width=220, height=100)
|
||||||
|
dpgg.multi_value_gauge(
|
||||||
|
tag="engine",
|
||||||
|
label="Engine",
|
||||||
|
values={"AFR": 12.8, "Oil": "58 psi"},
|
||||||
|
width=220,
|
||||||
|
height=110,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert get_gauge_state("battery").clamped_value == 100
|
||||||
|
assert (
|
||||||
|
"battery__battery_fill"
|
||||||
|
in dpgg.get_gauge_debug_state("battery")["renderer"]["layers"]["dynamic"]
|
||||||
|
)
|
||||||
|
text = dpgg.get_gauge_debug_state("engine")["renderer"]["layers"]["text"]
|
||||||
|
assert "engine__multi_0" in text
|
||||||
|
assert "engine__multi_1" in text
|
||||||
Reference in New Issue
Block a user