diff --git a/README.md b/README.md index d05ad0c..ace68fd 100644 --- a/README.md +++ b/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. diff --git a/codex/AGENTS.md b/codex/AGENTS.md index 0262e93..9d024f8 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -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. diff --git a/examples/basic_bar.py b/examples/basic_bar.py new file mode 100644 index 0000000..e7efc58 --- /dev/null +++ b/examples/basic_bar.py @@ -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() diff --git a/examples/basic_level.py b/examples/basic_level.py new file mode 100644 index 0000000..abe0a30 --- /dev/null +++ b/examples/basic_level.py @@ -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() diff --git a/examples/basic_status.py b/examples/basic_status.py new file mode 100644 index 0000000..2209c1b --- /dev/null +++ b/examples/basic_status.py @@ -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() diff --git a/examples/thresholds_markers.py b/examples/thresholds_markers.py new file mode 100644 index 0000000..a5cfcf8 --- /dev/null +++ b/examples/thresholds_markers.py @@ -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() diff --git a/src/dpg_gauges/renderer.py b/src/dpg_gauges/renderer.py index c267245..79873e9 100644 --- a/src/dpg_gauges/renderer.py +++ b/src/dpg_gauges/renderer.py @@ -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)) diff --git a/src/dpg_gauges/scales.py b/src/dpg_gauges/scales.py index 7e49040..d7820dd 100644 --- a/src/dpg_gauges/scales.py +++ b/src/dpg_gauges/scales.py @@ -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: diff --git a/src/dpg_gauges/state.py b/src/dpg_gauges/state.py index f9be7e4..fa900f0 100644 --- a/src/dpg_gauges/state.py +++ b/src/dpg_gauges/state.py @@ -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") ): diff --git a/src/dpg_gauges/widget.py b/src/dpg_gauges/widget.py index 7043ae0..789d41f 100644 --- a/src/dpg_gauges/widget.py +++ b/src/dpg_gauges/widget.py @@ -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) diff --git a/tests/test_step4_simple_gauges.py b/tests/test_step4_simple_gauges.py new file mode 100644 index 0000000..8993c02 --- /dev/null +++ b/tests/test_step4_simple_gauges.py @@ -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