From 1a476bc91d2919641ce0fa8362dba34410d90d32 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Thu, 2 Jul 2026 14:47:05 +0200 Subject: [PATCH] step 5: complete first gauge catalogue --- README.md | 11 +- codex/AGENTS.md | 29 +++- examples/basic_battery.py | 25 +++ examples/basic_compass.py | 23 +++ examples/basic_multi_value.py | 29 ++++ examples/basic_segmented.py | 32 ++++ examples/basic_thermometer.py | 32 ++++ src/dpg_gauges/renderer.py | 315 +++++++++++++++++++++++++++++++++- src/dpg_gauges/scales.py | 17 ++ src/dpg_gauges/state.py | 6 +- tests/test_step5_catalogue.py | 91 ++++++++++ 11 files changed, 594 insertions(+), 16 deletions(-) create mode 100644 examples/basic_battery.py create mode 100644 examples/basic_compass.py create mode 100644 examples/basic_multi_value.py create mode 100644 examples/basic_segmented.py create mode 100644 examples/basic_thermometer.py create mode 100644 tests/test_step5_catalogue.py diff --git a/README.md b/README.md index ace68fd..39c41d1 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,10 @@ Dear PyGui gauge widgets for dashboards and telemetry displays. -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. +Current implementation status: Step 5 first catalogue rendering is implemented. Analog, digital, +bar, level, status, segmented, compass, thermometer, battery, and multi-value gauges render Dear +PyGui drawlist visuals. Bar/level gauges draw threshold bands and markers; analog gauges draw ticks, +redline bands, markers, and needles. 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. diff --git a/codex/AGENTS.md b/codex/AGENTS.md index 9d024f8..aca5430 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -2,7 +2,7 @@ ## 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 @@ -28,7 +28,20 @@ Step 1 - Public API contract and pure core: ## 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: @@ -78,7 +91,7 @@ Implemented during Step 2: ## 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 @@ -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 format --check .`. - 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. - Implemented Step 2 state, sizing, animation, smoothing, runtime API, diagnostics, tests, and README status update. - 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 -Commit Step 4, then implement Step 5. +Commit Step 5, then implement Step 6. diff --git a/examples/basic_battery.py b/examples/basic_battery.py new file mode 100644 index 0000000..7115ca8 --- /dev/null +++ b/examples/basic_battery.py @@ -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() diff --git a/examples/basic_compass.py b/examples/basic_compass.py new file mode 100644 index 0000000..b88acb1 --- /dev/null +++ b/examples/basic_compass.py @@ -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() diff --git a/examples/basic_multi_value.py b/examples/basic_multi_value.py new file mode 100644 index 0000000..311c886 --- /dev/null +++ b/examples/basic_multi_value.py @@ -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() diff --git a/examples/basic_segmented.py b/examples/basic_segmented.py new file mode 100644 index 0000000..4919c61 --- /dev/null +++ b/examples/basic_segmented.py @@ -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() diff --git a/examples/basic_thermometer.py b/examples/basic_thermometer.py new file mode 100644 index 0000000..78cfc42 --- /dev/null +++ b/examples/basic_thermometer.py @@ -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() diff --git a/src/dpg_gauges/renderer.py b/src/dpg_gauges/renderer.py index 79873e9..1b55107 100644 --- a/src/dpg_gauges/renderer.py +++ b/src/dpg_gauges/renderer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math import time from typing import Any, cast @@ -9,9 +10,26 @@ import dearpygui.dearpygui as dpg from .animation import advance_animation from .draw_layers import DrawLayers -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 .gauges import ( + AnalogGaugeConfig, + 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 .state import DirtyFlags, GaugeState, animation_config, size_state_for_gauge, update_state_size from .themes import GaugeStyle @@ -102,8 +120,18 @@ class GaugeRenderer: self._draw_frame(width, height, style) if state.gauge_type == "digital": 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"}: 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": self._draw_status_light(width, height, style) else: @@ -321,6 +349,280 @@ class GaugeRenderer: 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: state = self.state 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] +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: if config.states is not None and config.state in config.states: return config.states[config.state] diff --git a/src/dpg_gauges/scales.py b/src/dpg_gauges/scales.py index d7820dd..3a641de 100644 --- a/src/dpg_gauges/scales.py +++ b/src/dpg_gauges/scales.py @@ -56,6 +56,13 @@ def value_to_angle( 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: return normalize_value(value, min_value, max_value, clamp=clamp) @@ -83,6 +90,16 @@ def normalized_span( 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]: validate_range(min_value, max_value) if count < 2: diff --git a/src/dpg_gauges/state.py b/src/dpg_gauges/state.py index fa900f0..3ddce76 100644 --- a/src/dpg_gauges/state.py +++ b/src/dpg_gauges/state.py @@ -9,8 +9,8 @@ from typing import Any from .animation import AnimationState, start_animation from .exceptions import GaugeConfigError, GaugeNotFoundError -from .gauges import GaugeConfig -from .scales import clamp_value, validate_markers, validate_thresholds +from .gauges import CompassGaugeConfig, GaugeConfig +from .scales import clamp_value, compass_heading, validate_markers, validate_thresholds from .sizing import RequestedSize, SizeState from .smoothing import SmoothingState 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 if not math.isfinite(numeric): return None, True + if isinstance(config, CompassGaugeConfig): + numeric = compass_heading(numeric) if config.clamp: return clamp_value(numeric, config.min_value, config.max_value), False return numeric, False diff --git a/tests/test_step5_catalogue.py b/tests/test_step5_catalogue.py new file mode 100644 index 0000000..14ca624 --- /dev/null +++ b/tests/test_step5_catalogue.py @@ -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