diff --git a/README.md b/README.md index e19716f..d05ad0c 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ Dear PyGui gauge widgets for dashboards and telemetry displays. -Current implementation status: Step 2 logical state is implemented. Gauge creation functions build -and register pure gauge state without requiring a Dear PyGui context; final widget shells and drawing -arrive in later steps. +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. -Runtime value updates are intended for the Dear PyGui GUI thread once rendering is implemented. +Runtime value updates are intended for the Dear PyGui GUI thread. Attached gauges redraw immediately +for simple value/configuration changes. diff --git a/codex/AGENTS.md b/codex/AGENTS.md index bc0d087..0262e93 100644 --- a/codex/AGENTS.md +++ b/codex/AGENTS.md @@ -2,7 +2,7 @@ ## Current status -Step 2 completed and verified. Ready to start Step 3. +Step 3 completed and verified with automated checks. Ready to start Step 4. ## Completed steps @@ -28,7 +28,18 @@ Step 1 - Public API contract and pure core: ## Current step -Step 3 - Widget shell and renderer foundation. +Step 4 - Digital, bar, level, and status gauges. + +Step 3 - Widget shell and renderer foundation: + +Implemented during Step 3: + +- Added Dear PyGui widget shell creation for gauge context managers when a DPG context is active. +- Kept pure registration behavior when no DPG context has been created. +- Added a renderer foundation that measures drawlist size, redraws stable placeholder background/frame/value text, schedules animation frames, and exposes renderer debug fields. +- Added draw layer bookkeeping. +- Added lightweight `gauge_panel` and `gauge_grid` containers. +- Added Step 3 examples and smoke tests. Step 2 - State, sizing, animation, and smoothing model: @@ -56,9 +67,9 @@ Implemented during Step 2: - The public API should feel like normal Dear PyGui context-manager usage. - Each completed build step must be checked, documented in this file, and committed before starting the next step. -## Known issues +## Known issues -None yet. +Manual visual example checks were not run in this headless session. ## Commands used @@ -84,7 +95,14 @@ None yet. - Ran `uv run ruff format --check .`. - Ran `uv run pyright`. - Updated instructions to require a commit after each completed step. +- Read `codex/FEATURES.md`, `codex/ARCHITECTURE.md`, `codex/STEPS.md`, and `codex/AGENTS.md` before Step 3 changes. +- Implemented Step 3 widget shells, renderer foundation, draw layer bookkeeping, layout helpers, examples, smoke tests, README status update, and diagnostics renderer fields. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. -## Next action +## Next action -Implement Step 3. +Implement Step 4. diff --git a/examples/basic_analog.py b/examples/basic_analog.py new file mode 100644 index 0000000..e0e6bab --- /dev/null +++ b/examples/basic_analog.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="Analog Gauge", width=320, height=320): + dpgg.analog_gauge( + tag="rpm", label="RPM", value=3250, max_value=8000, width=240, height=240 + ) + + dpg.create_viewport(title="dpg-gauges analog", 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_digital.py b/examples/basic_digital.py new file mode 100644 index 0000000..8f0752f --- /dev/null +++ b/examples/basic_digital.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="Digital Gauge", width=320, height=180): + dpgg.digital_gauge( + tag="speed", label="Speed", value=72, unit="km/h", width=260, height=100 + ) + + dpg.create_viewport(title="dpg-gauges digital", 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/dashboard.py b/examples/dashboard.py new file mode 100644 index 0000000..cbff3d4 --- /dev/null +++ b/examples/dashboard.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: + with ( + dpg.window(label="Dashboard", width=620, height=360), + dpgg.gauge_panel(label="Engine", width=580, height=260), + dpgg.gauge_grid(columns=3), + ): + dpgg.analog_gauge( + tag="rpm", label="RPM", value=4200, max_value=8000, width=180, height=180 + ) + dpgg.digital_gauge( + tag="speed", label="Speed", value=88, unit="km/h", width=160, height=90 + ) + dpgg.digital_gauge( + tag="temp", label="Coolant", value=91, unit="C", width=160, height=90 + ) + + dpg.create_viewport(title="dpg-gauges dashboard", width=660, height=400) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/sizing.py b/examples/sizing.py new file mode 100644 index 0000000..f7ee2ba --- /dev/null +++ b/examples/sizing.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="Sizing", width=520, height=300): + dpgg.digital_gauge(tag="fixed", label="Fixed", value=12.3, width=180, height=80) + dpgg.digital_gauge(tag="wide", label="Fill width", value=45.6, width=-1, height=80) + dpgg.analog_gauge(tag="square", label="Square", value=60, width=180, height=180) + + dpg.create_viewport(title="dpg-gauges sizing", width=560, height=340) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/src/dpg_gauges/api.py b/src/dpg_gauges/api.py index 8989b25..ac513be 100644 --- a/src/dpg_gauges/api.py +++ b/src/dpg_gauges/api.py @@ -2,27 +2,14 @@ from __future__ import annotations -from contextlib import nullcontext -from typing import Any, cast +from typing import Any -from .gauges import ( - AnalogGaugeConfig, - BarGaugeConfig, - BatteryGaugeConfig, - CompassGaugeConfig, - DigitalGaugeConfig, - GaugeConfig, - LevelGaugeConfig, - MultiValueGaugeConfig, - SegmentedGaugeConfig, - StatusLightConfig, - ThermometerGaugeConfig, -) +from . import widget as _widget +from .gauges import GaugeConfig from .state import ( configure_gauges, delete_runtime_gauge, get_runtime_value, - register_gauge, set_runtime_markers, set_runtime_show, set_runtime_thresholds, @@ -36,62 +23,18 @@ def configure(**config: Any) -> None: configure_gauges(**config) -def analog_gauge(**config: Any) -> nullcontext[AnalogGaugeConfig]: - state = register_gauge("analog", AnalogGaugeConfig(**config)) - return nullcontext(cast(AnalogGaugeConfig, state.config)) - - -def digital_gauge(**config: Any) -> nullcontext[DigitalGaugeConfig]: - state = register_gauge("digital", DigitalGaugeConfig(**config)) - return nullcontext(cast(DigitalGaugeConfig, state.config)) - - -def bar_gauge(**config: Any) -> nullcontext[BarGaugeConfig]: - state = register_gauge("bar", BarGaugeConfig(**config)) - return nullcontext(cast(BarGaugeConfig, state.config)) - - -def level_gauge(**config: Any) -> nullcontext[LevelGaugeConfig]: - state = register_gauge("level", LevelGaugeConfig(**config)) - return nullcontext(cast(LevelGaugeConfig, state.config)) - - -def status_light(**config: Any) -> nullcontext[StatusLightConfig]: - state = register_gauge("status_light", StatusLightConfig(**config)) - return nullcontext(cast(StatusLightConfig, state.config)) - - -def segmented_gauge(**config: Any) -> nullcontext[SegmentedGaugeConfig]: - state = register_gauge("segmented", SegmentedGaugeConfig(**config)) - return nullcontext(cast(SegmentedGaugeConfig, state.config)) - - -def compass_gauge(**config: Any) -> nullcontext[CompassGaugeConfig]: - state = register_gauge("compass", CompassGaugeConfig(**config)) - return nullcontext(cast(CompassGaugeConfig, state.config)) - - -def thermometer_gauge(**config: Any) -> nullcontext[ThermometerGaugeConfig]: - state = register_gauge("thermometer", ThermometerGaugeConfig(**config)) - return nullcontext(cast(ThermometerGaugeConfig, state.config)) - - -def battery_gauge(**config: Any) -> nullcontext[BatteryGaugeConfig]: - state = register_gauge("battery", BatteryGaugeConfig(**config)) - return nullcontext(cast(BatteryGaugeConfig, state.config)) - - -def multi_value_gauge(**config: Any) -> nullcontext[MultiValueGaugeConfig]: - state = register_gauge("multi_value", MultiValueGaugeConfig(**config)) - return nullcontext(cast(MultiValueGaugeConfig, state.config)) - - -def gauge_panel(**_config: Any) -> nullcontext[None]: - return nullcontext(None) - - -def gauge_grid(**_config: Any) -> nullcontext[None]: - return nullcontext(None) +analog_gauge = _widget.analog_gauge +digital_gauge = _widget.digital_gauge +bar_gauge = _widget.bar_gauge +level_gauge = _widget.level_gauge +status_light = _widget.status_light +segmented_gauge = _widget.segmented_gauge +compass_gauge = _widget.compass_gauge +thermometer_gauge = _widget.thermometer_gauge +battery_gauge = _widget.battery_gauge +multi_value_gauge = _widget.multi_value_gauge +gauge_panel = _widget.gauge_panel +gauge_grid = _widget.gauge_grid def set_value(tag: Tag, value: GaugeValue) -> None: diff --git a/src/dpg_gauges/diagnostics.py b/src/dpg_gauges/diagnostics.py index d9326aa..14d1c19 100644 --- a/src/dpg_gauges/diagnostics.py +++ b/src/dpg_gauges/diagnostics.py @@ -10,7 +10,7 @@ from .types import Tag def get_gauge_debug_state(tag: Tag) -> dict[str, Any]: state = get_gauge_state(tag) - return { + debug = { "tag": state.tag, "gauge_type": state.gauge_type, "target_value": state.target_value, @@ -26,3 +26,6 @@ def get_gauge_debug_state(tag: Tag) -> dict[str, Any]: "animation_active": state.animation_state.active, "smoothing_active": state.smoothing_state.active, } + if state.renderer is not None: + debug["renderer"] = state.renderer.debug_state() + return debug diff --git a/src/dpg_gauges/draw_layers.py b/src/dpg_gauges/draw_layers.py index e69de29..a1ee462 100644 --- a/src/dpg_gauges/draw_layers.py +++ b/src/dpg_gauges/draw_layers.py @@ -0,0 +1,44 @@ +"""Bookkeeping for Dear PyGui drawlist layers.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .types import Tag + + +@dataclass +class DrawLayer: + name: str + item_tags: set[Tag] = field(default_factory=set) + + def add(self, tag: Tag) -> Tag: + self.item_tags.add(tag) + return tag + + def clear(self) -> None: + self.item_tags.clear() + + +@dataclass +class DrawLayers: + background: DrawLayer = field(default_factory=lambda: DrawLayer("background")) + static: DrawLayer = field(default_factory=lambda: DrawLayer("static")) + thresholds: DrawLayer = field(default_factory=lambda: DrawLayer("thresholds")) + dynamic: DrawLayer = field(default_factory=lambda: DrawLayer("dynamic")) + markers: DrawLayer = field(default_factory=lambda: DrawLayer("markers")) + text: DrawLayer = field(default_factory=lambda: DrawLayer("text")) + + def all_layers(self) -> tuple[DrawLayer, ...]: + return ( + self.background, + self.static, + self.thresholds, + self.dynamic, + self.markers, + self.text, + ) + + def clear(self) -> None: + for layer in self.all_layers(): + layer.clear() diff --git a/src/dpg_gauges/renderer.py b/src/dpg_gauges/renderer.py index e69de29..c267245 100644 --- a/src/dpg_gauges/renderer.py +++ b/src/dpg_gauges/renderer.py @@ -0,0 +1,166 @@ +"""Dear PyGui renderer foundation for gauge placeholders.""" + +from __future__ import annotations + +import time +from typing import Any + +import dearpygui.dearpygui as dpg + +from .animation import advance_animation +from .draw_layers import DrawLayers +from .scales import format_gauge_value +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 + + +class GaugeRenderer: + """Small renderer that keeps a gauge drawlist populated between updates.""" + + def __init__(self, state: GaugeState) -> None: + self.state = state + self.layers = DrawLayers() + self._scheduled = False + + def render(self) -> None: + state = self.state + self._measure() + self._advance_values() + self._redraw_placeholder() + state.dirty = DirtyFlags.NONE + if state.animation_state.active or state.smoothing_state.active: + self.schedule_frame() + + def delete(self) -> None: + if dpg.does_item_exist(self.state.container_tag): + dpg.delete_item(self.state.container_tag) + + def schedule_frame(self) -> None: + if self._scheduled: + return + self._scheduled = True + frame = dpg.get_frame_count() + 1 + dpg.set_frame_callback(frame, self._frame_callback) + + def debug_state(self) -> dict[str, Any]: + state = self.state + return { + "container_tag": state.container_tag, + "drawlist_tag": state.drawlist_tag, + "layers": {layer.name: tuple(layer.item_tags) for layer in self.layers.all_layers()}, + "scheduled": self._scheduled, + } + + def _frame_callback(self) -> None: + self._scheduled = False + self.state.dirty |= DirtyFlags.DYNAMIC | DirtyFlags.TEXT + self.render() + + def _measure(self) -> None: + 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) + if update_measured_size(size, width, height): + update_state_size(state, size) + + def _advance_values(self) -> None: + state = self.state + now = time.monotonic() + config = animation_config(state.config) + if state.animation_state.active: + state.displayed_value, active = advance_animation(state.animation_state, config, now) + state.animation_state.active = active + smoothing = _smoothing_config(state) + if not state.animation_state.active and smoothing is not None and smoothing.enabled: + state.displayed_value, _ = advance_smoothing( + state.smoothing_state, state.clamped_value, smoothing + ) + + def _redraw_placeholder(self) -> None: + state = self.state + if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag): + return + + width, height = effective_size(size_state_for_gauge(state)) + width = width or max(state.config.width, 1) or 180 + height = height or max(state.config.height, 1) or 80 + dpg.configure_item(state.drawlist_tag, width=width, height=height, show=state.config.show) + dpg.configure_item(state.container_tag, show=state.config.show) + dpg.delete_item(state.drawlist_tag, children_only=True) + self.layers.clear() + + style = state.config.style or GaugeStyle() + 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, + tag=background, + color=style.border_color, + fill=style.background_color, + rounding=6, + thickness=1, + ) + dpg.draw_rectangle( + (4, 4), + (max(width - 4, 4), max(height - 4, 4)), + parent=state.drawlist_tag, + tag=frame, + color=style.frame_color, + rounding=4, + thickness=1, + ) + + label_y = 10 + if state.config.show_label and state.config.label: + label = self.layers.text.add(f"{state.tag}__label") + dpg.draw_text( + (12, label_y), + state.config.label, + parent=state.drawlist_tag, + tag=label, + color=style.muted_text_color, + size=14, + ) + + value = self._display_text() + 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, + tag=value_tag, + color=style.value_color if not state.invalid_value else style.redline_color, + size=24, + ) + + def _display_text(self) -> str: + state = self.state + if not state.config.show_value: + return "" + if state.invalid_value or state.displayed_value is None: + return "--" + if state.config.format_value is not None: + return state.config.format_value(state.displayed_value) + unit = state.config.unit if state.config.show_unit else "" + return format_gauge_value(state.displayed_value, state.config.precision, unit) + + +def render_attached_gauge(state: GaugeState) -> None: + if state.renderer is not None: + state.renderer.render() + + +def _smoothing_config(state: GaugeState) -> SmoothingConfig | None: + smoothing = state.config.smoothing + if isinstance(smoothing, bool): + return SmoothingConfig(enabled=smoothing) + return smoothing diff --git a/src/dpg_gauges/state.py b/src/dpg_gauges/state.py index f62bb18..f9be7e4 100644 --- a/src/dpg_gauges/state.py +++ b/src/dpg_gauges/state.py @@ -216,6 +216,7 @@ def set_runtime_value(tag: Tag, value: GaugeValue, *, now: float = 0.0) -> None: state.displayed_value = clamped state.smoothing_state.value = clamped state.dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT + render_runtime_gauge(state) def get_runtime_value(tag: Tag) -> GaugeValue: @@ -257,6 +258,7 @@ def update_runtime_gauge(tag: Tag, **config: Any) -> None: dirty |= DirtyFlags.VALUE | DirtyFlags.DYNAMIC | DirtyFlags.TEXT state.dirty |= dirty + render_runtime_gauge(state) def set_runtime_thresholds( @@ -278,10 +280,17 @@ def set_runtime_show(tag: Tag, show: bool) -> None: def delete_runtime_gauge(tag: Tag) -> None: - get_gauge_state(tag) + state = get_gauge_state(tag) + if state.renderer is not None: + state.renderer.delete() del _registry[tag] +def render_runtime_gauge(state: GaugeState) -> None: + if state.renderer is not None: + state.renderer.render() + + def update_state_size(state: GaugeState, size: SizeState) -> None: state.measured_width = size.measured_width state.measured_height = size.measured_height diff --git a/src/dpg_gauges/widget.py b/src/dpg_gauges/widget.py index e69de29..7043ae0 100644 --- a/src/dpg_gauges/widget.py +++ b/src/dpg_gauges/widget.py @@ -0,0 +1,227 @@ +"""Dear PyGui widget shell creation helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import TracebackType +from typing import Any, Generic, TypeVar, cast + +import dearpygui.dearpygui as dpg + +from .gauges import ( + AnalogGaugeConfig, + BarGaugeConfig, + BatteryGaugeConfig, + CompassGaugeConfig, + DigitalGaugeConfig, + GaugeConfig, + LevelGaugeConfig, + MultiValueGaugeConfig, + SegmentedGaugeConfig, + StatusLightConfig, + ThermometerGaugeConfig, +) +from .renderer import GaugeRenderer +from .state import GaugeState, register_gauge +from .types import Tag + +ConfigT = TypeVar("ConfigT", bound=GaugeConfig) + +_dpg_context_active = False +_original_create_context = dpg.create_context +_original_destroy_context = dpg.destroy_context + + +def _install_context_tracking() -> None: + if getattr(dpg.create_context, "__dpg_gauges_wrapped__", False): + return + + def create_context(*args: Any, **kwargs: Any) -> Any: + global _dpg_context_active + result = _original_create_context(*args, **kwargs) + _dpg_context_active = True + return result + + def destroy_context(*args: Any, **kwargs: Any) -> Any: + global _dpg_context_active + try: + return _original_destroy_context(*args, **kwargs) + finally: + _dpg_context_active = False + + create_context.__dpg_gauges_wrapped__ = True # type: ignore[attr-defined] + destroy_context.__dpg_gauges_wrapped__ = True # type: ignore[attr-defined] + dpg.create_context = create_context + dpg.destroy_context = destroy_context + + +_install_context_tracking() + + +@dataclass +class GaugeContext(Generic[ConfigT]): + state: GaugeState + + def __enter__(self) -> ConfigT: + if _dpg_context_active and dpg.does_item_exist(self.state.container_tag): + dpg.push_container_stack(self.state.container_tag) + return cast(ConfigT, self.state.config) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if _dpg_context_active and dpg.does_item_exist(self.state.container_tag): + dpg.pop_container_stack() + + +@dataclass +class LayoutContext: + tag: Tag | None + + def __enter__(self) -> Tag | None: + if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag): + dpg.push_container_stack(self.tag) + return self.tag + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag): + dpg.pop_container_stack() + + +def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[ConfigT]: + if not _dpg_context_active: + return GaugeContext(register_gauge(gauge_type, config)) + + tag = config.tag + container_tag = tag if tag is not None else None + drawlist_tag = f"{tag}__drawlist" if tag is not None else None + width = config.width + height = config.height or _default_height(gauge_type) + if height == -1: + height = _default_height(gauge_type) + + container = dpg.add_group( + tag=container_tag or 0, + width=width, + height=height, + show=config.show, + user_data=config.user_data, + ) + drawlist = dpg.add_drawlist( + max(width, 1) if width > 0 else _default_width(gauge_type), + max(height, 1), + tag=drawlist_tag or 0, + parent=container, + show=config.show, + ) + _add_tooltip(container, config.tooltip) + state = register_gauge( + gauge_type, + config, + container_tag=container, + drawlist_tag=drawlist, + text_tags={}, + ) + renderer = GaugeRenderer(state) + state.renderer = renderer + renderer.render() + return GaugeContext(state) + + +def gauge_panel(**config: Any) -> LayoutContext: + if not _dpg_context_active: + return LayoutContext(None) + tag = config.pop("tag", 0) + label = config.pop("label", None) + tooltip = config.pop("tooltip", None) + container = dpg.add_child_window( + tag=tag, + label=label, + width=config.pop("width", 0), + height=config.pop("height", 0), + show=config.pop("show", True), + border=config.pop("border", True), + autosize_x=config.pop("autosize_x", False), + autosize_y=config.pop("autosize_y", False), + **config, + ) + _add_tooltip(container, tooltip) + return LayoutContext(container) + + +def gauge_grid(**config: Any) -> LayoutContext: + if not _dpg_context_active: + return LayoutContext(None) + columns = max(int(config.pop("columns", 1)), 1) + container = dpg.add_group( + tag=config.pop("tag", 0), + width=config.pop("width", 0), + height=config.pop("height", 0), + show=config.pop("show", True), + horizontal=columns > 1, + **config, + ) + return LayoutContext(container) + + +def analog_gauge(**config: Any) -> GaugeContext[AnalogGaugeConfig]: + return create_gauge_context("analog", AnalogGaugeConfig(**config)) + + +def digital_gauge(**config: Any) -> GaugeContext[DigitalGaugeConfig]: + return create_gauge_context("digital", DigitalGaugeConfig(**config)) + + +def bar_gauge(**config: Any) -> GaugeContext[BarGaugeConfig]: + return create_gauge_context("bar", BarGaugeConfig(**config)) + + +def level_gauge(**config: Any) -> GaugeContext[LevelGaugeConfig]: + return create_gauge_context("level", LevelGaugeConfig(**config)) + + +def status_light(**config: Any) -> GaugeContext[StatusLightConfig]: + return create_gauge_context("status_light", StatusLightConfig(**config)) + + +def segmented_gauge(**config: Any) -> GaugeContext[SegmentedGaugeConfig]: + return create_gauge_context("segmented", SegmentedGaugeConfig(**config)) + + +def compass_gauge(**config: Any) -> GaugeContext[CompassGaugeConfig]: + return create_gauge_context("compass", CompassGaugeConfig(**config)) + + +def thermometer_gauge(**config: Any) -> GaugeContext[ThermometerGaugeConfig]: + return create_gauge_context("thermometer", ThermometerGaugeConfig(**config)) + + +def battery_gauge(**config: Any) -> GaugeContext[BatteryGaugeConfig]: + return create_gauge_context("battery", BatteryGaugeConfig(**config)) + + +def multi_value_gauge(**config: Any) -> GaugeContext[MultiValueGaugeConfig]: + return create_gauge_context("multi_value", MultiValueGaugeConfig(**config)) + + +def _default_width(gauge_type: str) -> int: + return 220 if gauge_type in {"analog", "compass"} else 180 + + +def _default_height(gauge_type: str) -> int: + return 220 if gauge_type in {"analog", "compass"} else 80 + + +def _add_tooltip(parent: Tag, tooltip: str | None) -> None: + if not tooltip: + return + tooltip_tag = dpg.add_tooltip(parent) + dpg.add_text(tooltip, parent=tooltip_tag) diff --git a/tests/test_step3_widget_renderer.py b/tests/test_step3_widget_renderer.py new file mode 100644 index 0000000..e8d8296 --- /dev/null +++ b/tests/test_step3_widget_renderer.py @@ -0,0 +1,78 @@ +# pyright: reportGeneralTypeIssues=false + +from collections.abc import Generator + +import dearpygui.dearpygui as dpg +import pytest + +import dpg_gauges as dpgg +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_dpg_gauge_shell_registers_before_yield(dpg_context: None) -> None: + with ( + dpg.window(label="Smoke"), + dpgg.digital_gauge(tag="speed", label="Speed", value=42, width=180, height=80) as config, + ): + state = get_gauge_state("speed") + assert config.tag == "speed" + assert state.renderer is not None + assert dpg.does_item_exist(state.container_tag) + assert state.drawlist_tag is not None + assert dpg.does_item_exist(state.drawlist_tag) + + +def test_runtime_update_redraws_attached_gauge(dpg_context: None) -> None: + with dpg.window(label="Smoke"): + dpgg.digital_gauge(tag="rpm", label="RPM", value=1000, max_value=8000, width=200, height=90) + + dpgg.set_value("rpm", 2500) + + state = get_gauge_state("rpm") + debug = dpgg.get_gauge_debug_state("rpm") + assert state.displayed_value == 2500 + assert state.dirty == 0 + assert debug["renderer"]["drawlist_tag"] == "rpm__drawlist" + assert "rpm__value" in debug["renderer"]["layers"]["dynamic"] + + +def test_hidden_gauge_preserves_configured_size(dpg_context: None) -> None: + with dpg.window(label="Hidden"): + dpgg.digital_gauge(tag="hidden", value=1, width=160, height=70, show=False) + + state = get_gauge_state("hidden") + assert state.measured_width == 160 + assert state.measured_height == 70 + assert state.last_nonzero_width == 160 + assert state.last_nonzero_height == 70 + + dpgg.set_show("hidden", True) + assert dpg.does_item_exist(state.container_tag) + assert state.config.show is True + + +def test_panel_and_grid_are_lightweight_containers(dpg_context: None) -> None: + with ( + dpg.window(label="Dashboard"), + dpgg.gauge_panel(tag="engine", label="Engine", width=300, height=180) as panel, + dpgg.gauge_grid(tag="engine_grid", columns=2) as grid, + ): + assert panel == "engine" + assert grid == "engine_grid" + dpgg.digital_gauge(tag="temp", label="Temp", value=90, width=120, height=60) + + assert dpg.does_item_exist("engine") + assert dpg.does_item_exist("engine_grid") + assert get_gauge_state("temp").renderer is not None