5 Commits

Author SHA1 Message Date
0a7b4f0172 Release 1.1.0 2026-07-07 23:12:07 +01:00
5bd819d812 Clarify grid autobreak behavior 2026-07-07 23:06:53 +01:00
44403bea2b Improve gauge grid layout 2026-07-07 23:00:49 +01:00
ceba71927d Release 1.0.2 2026-07-07 16:42:15 +01:00
ac678e8dce Release 1.0.1 2026-07-07 17:17:28 +02:00
14 changed files with 379 additions and 32 deletions

View File

@@ -37,12 +37,29 @@ dpg.destroy_context()
```python
with dpg.window(label="Engine"):
with dpgg.gauge_panel(label="Powertrain", width=-1, height=260):
with dpgg.gauge_grid(columns=3):
with dpgg.gauge_grid(columns=3, min_column_width=180):
dpgg.analog_gauge(tag="rpm", label="RPM", max_value=8000, redline_start=6500)
dpgg.bar_gauge(tag="boost", label="Boost", max_value=30, unit="psi")
dpgg.status_light(tag="ecu", label="ECU", state=True)
```
`gauge_grid` is a wrapping, table-backed layout. An integer `columns` value is a maximum; if the
parent is too narrow for `columns * min_column_width`, the grid uses fewer columns and continues on
the next row instead of creating a horizontal scrollbar. Use `columns="auto"` for autobreak mode,
where the grid derives the column count from the available width. Gauges with `width=0` or
`width=-1` fill their grid cell.
Gauges also accept Dear PyGui's usual `parent` argument for adding a gauge to an
existing container after that container has already been created:
```python
dpg.add_window(tag="data_input", label="Data Input")
dpgg.analog_gauge(tag="rpm", parent="data_input", label="RPM")
```
The `parent` is applied to the gauge's outer group. The gauge's internal drawlist
is then parented to that group.
## GUI-Thread Contract
Dear PyGui item operations must happen on the GUI thread. Gauge creation and runtime updates such as

View File

@@ -24,7 +24,14 @@ Creation functions are context managers and accept common options such as `tag`,
## Layout Helpers
- `gauge_panel(**config)`: child-window panel for grouped gauges.
- `gauge_grid(**config)`: lightweight horizontal gauge group.
- `gauge_grid(columns=1, min_column_width=180, fit_gauges=True, cell_padding=12, **config)`:
table-backed
wrapping gauge grid. Pass an integer `columns` to set the maximum column count; if the parent is
narrower than `columns * min_column_width`, the grid uses fewer columns and wraps gauges onto
later rows to avoid horizontal scrolling. Pass `columns="auto"` for autobreak mode, where the
column count is derived entirely from the available width and `min_column_width`. Gauges without
an explicit positive width fill their grid cell. Increase `cell_padding` if a custom Dear PyGui
theme uses wider table padding or borders.
## Runtime API

View File

@@ -15,6 +15,7 @@ Run examples with `uv run python examples/<name>.py`.
- `thresholds_markers.py`
- `animations.py`
- `dashboard.py`
- `layout_grid.py`
- `sizing.py`
- `live_high_rate.py`
- `live_worst_case.py`

65
examples/layout_grid.py Normal file
View File

@@ -0,0 +1,65 @@
# pyright: reportGeneralTypeIssues=false
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
def main() -> None:
dpg.create_context()
try:
with dpg.window(label="Grid layout", width=820, height=620):
dpg.add_text("Max columns: columns=3 wraps onto new rows after every third gauge")
with dpgg.gauge_panel(label="Wide panel", width=-1, height=270), dpgg.gauge_grid(
columns=3, min_column_width=190
):
dpgg.analog_gauge(
tag="grid_rpm", label="RPM", value=4200, max_value=8000, height=190
)
dpgg.digital_gauge(
tag="grid_speed", label="Speed", value=88, unit="km/h", height=90
)
dpgg.bar_gauge(
tag="grid_boost", label="Boost", value=12.5, max_value=30, unit="psi"
)
dpgg.status_light(tag="grid_ecu", label="ECU", state=True, height=70)
dpgg.battery_gauge(tag="grid_battery", label="Battery", value=74, height=160)
dpg.add_spacer(height=8)
dpg.add_text("Autobreak: columns='auto' chooses columns from available width")
with (
dpgg.gauge_panel(label="Autobreak narrow panel", width=430, height=285),
dpgg.gauge_grid(columns="auto", min_column_width=180),
):
for index, value in enumerate((18, 42, 67, 91, 55), start=1):
dpgg.digital_gauge(
tag=f"narrow_{index}",
label=f"Metric {index}",
value=value,
unit="%",
height=72,
)
dpg.add_spacer(height=8)
dpg.add_text("Panels can also occupy grid cells")
with dpgg.gauge_grid(columns=2, min_column_width=240):
with dpgg.gauge_panel(label="Left cell", height=120):
dpgg.segmented_gauge(tag="left_segments", label="Load", value=70, height=80)
with dpgg.gauge_panel(label="Right cell", height=120):
dpgg.multi_value_gauge(
tag="right_values",
label="Sensors",
values={"oil": 82, "fuel": 64, "air": 31},
height=92,
)
dpg.create_viewport(title="dpg-gauges grid layout", width=860, height=660)
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
finally:
dpg.destroy_context()
if __name__ == "__main__":
main()

View File

@@ -1,6 +1,6 @@
[project]
name = "dpg-gauges"
version = "1.0.0"
version = "1.1.0"
description = "Dear PyGui gauge widgets for dashboards and telemetry displays"
readme = "README.md"
authors = [

View File

@@ -35,7 +35,7 @@ from .types import (
ThresholdBand,
)
__version__ = "1.0.0"
__version__ = "1.1.0"
__all__ = [
"configure",

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from typing import Any
from . import widget as _widget
from .gauges import GaugeConfig
from .state import (
configure_gauges,
@@ -17,6 +16,44 @@ from .state import (
update_runtime_gauge,
)
from .types import GaugeMarker, GaugeValue, Tag, ThresholdBand
from .widget import (
analog_gauge,
bar_gauge,
battery_gauge,
compass_gauge,
digital_gauge,
gauge_grid,
gauge_panel,
level_gauge,
multi_value_gauge,
segmented_gauge,
status_light,
thermometer_gauge,
)
__all__ = [
"analog_gauge",
"bar_gauge",
"battery_gauge",
"compass_gauge",
"configure",
"configure_gauge",
"delete_gauge",
"digital_gauge",
"gauge_grid",
"gauge_panel",
"get_value",
"level_gauge",
"multi_value_gauge",
"segmented_gauge",
"set_markers",
"set_show",
"set_thresholds",
"set_value",
"status_light",
"thermometer_gauge",
"update_gauge",
]
def configure(**config: Any) -> None:
@@ -24,20 +61,6 @@ def configure(**config: Any) -> None:
configure_gauges(**config)
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:
"""Set a gauge target value from the Dear PyGui GUI thread."""
set_runtime_value(tag, value)
@@ -78,4 +101,4 @@ def delete_gauge(tag: Tag) -> None:
delete_runtime_gauge(tag)
__all_configs__ = (GaugeConfig,)
__all_configs__: tuple[type[GaugeConfig], ...] = (GaugeConfig,)

View File

@@ -36,6 +36,7 @@ def _normalize_smoothing(value: SmoothingConfig | bool | None) -> SmoothingConfi
@dataclass(frozen=True)
class GaugeConfig:
tag: Tag | None = None
parent: Tag | None = None
label: str | None = None
value: GaugeValue = None
min_value: float = 0.0

0
src/dpg_gauges/py.typed Normal file
View File

View File

@@ -50,7 +50,7 @@ class GaugeRenderer:
def __init__(self, state: GaugeState) -> None:
self.state = state
self.layers = DrawLayers()
self.layers: DrawLayers = DrawLayers()
self._scheduled = False
def render(self) -> None:

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, replace
from types import TracebackType
from typing import Any, Generic, Literal, TypeVar, cast
@@ -42,6 +42,8 @@ ConfigT = TypeVar("ConfigT", bound=GaugeConfig)
_dpg_context_active = False
_original_create_context = dpg.create_context
_original_destroy_context = dpg.destroy_context
_active_grids: list[GridLayoutContext] = []
_layout_suspend_depth = 0
def _install_context_tracking() -> None:
@@ -73,10 +75,14 @@ _install_context_tracking()
@dataclass
class GaugeContext(Generic[ConfigT]):
state: GaugeState
_suspended_layout: bool = False
def __enter__(self) -> ConfigT:
global _layout_suspend_depth
if _dpg_context_active and dpg.does_item_exist(self.state.container_tag):
dpg.push_container_stack(self.state.container_tag)
_layout_suspend_depth += 1
self._suspended_layout = True
return cast(ConfigT, self.state.config)
def __exit__(
@@ -85,17 +91,27 @@ class GaugeContext(Generic[ConfigT]):
exc: BaseException | None,
traceback: TracebackType | None,
) -> None:
global _layout_suspend_depth
if _dpg_context_active and dpg.does_item_exist(self.state.container_tag):
dpg.pop_container_stack()
if self._suspended_layout:
_layout_suspend_depth = max(_layout_suspend_depth - 1, 0)
self._suspended_layout = False
@dataclass
class LayoutContext:
tag: Tag | None
suspend_layout: bool = True
_suspended_layout: bool = False
def __enter__(self) -> Tag | None:
global _layout_suspend_depth
if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag):
dpg.push_container_stack(self.tag)
if self.suspend_layout:
_layout_suspend_depth += 1
self._suspended_layout = True
return self.tag
def __exit__(
@@ -104,14 +120,70 @@ class LayoutContext:
exc: BaseException | None,
traceback: TracebackType | None,
) -> None:
global _layout_suspend_depth
if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag):
dpg.pop_container_stack()
if self._suspended_layout:
_layout_suspend_depth = max(_layout_suspend_depth - 1, 0)
self._suspended_layout = False
@dataclass
class GridLayoutContext:
tag: Tag | None
columns: int
min_column_width: int
fit_gauges: bool
cell_padding: int = 12
row_height: int = 0
available_width: int = 0
_next_index: int = 0
_current_row: Tag | None = None
_suspend_depth: int = 0
def __enter__(self) -> Tag | None:
self._suspend_depth = _layout_suspend_depth
if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag):
_active_grids.append(self)
return self.tag
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: TracebackType | None,
) -> None:
if _active_grids and _active_grids[-1] is self:
_active_grids.pop()
elif self in _active_grids:
_active_grids.remove(self)
def next_cell(self) -> tuple[Tag | None, int | None]:
if self.tag is None or not _dpg_context_active or not dpg.does_item_exist(self.tag):
return None, None
if self._next_index % self.columns == 0:
self._current_row = dpg.add_table_row(parent=self.tag, height=self.row_height)
cell = dpg.add_table_cell(parent=self._current_row or self.tag)
self._next_index += 1
return cell, self.cell_width()
def cell_width(self) -> int | None:
if self.available_width <= 0:
return None
return max(int(self.available_width / self.columns) - self.cell_padding, 1)
def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[ConfigT]:
if not _dpg_context_active:
return GaugeContext(register_gauge(gauge_type, config))
layout_parent, layout_width = _next_layout_cell(config.parent)
if layout_parent is not None:
updates: dict[str, Any] = {"parent": layout_parent}
if layout_width is not None and _current_grid_fits_gauges() and config.width <= 0:
updates["width"] = layout_width
config = replace(config, **updates)
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
@@ -122,6 +194,7 @@ def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[Confi
container = dpg.add_group(
tag=container_tag or 0,
parent=config.parent or 0,
width=width,
height=height,
show=config.show,
@@ -150,9 +223,59 @@ def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[Confi
return GaugeContext(state)
def _next_layout_cell(parent: Tag | None) -> tuple[Tag | None, int | None]:
grid = _current_grid()
if parent is not None or grid is None:
return None, None
return grid.next_cell()
def _current_grid() -> GridLayoutContext | None:
if not _active_grids:
return None
grid = _active_grids[-1]
if grid._suspend_depth != _layout_suspend_depth:
return None
return grid
def _current_grid_fits_gauges() -> bool:
grid = _current_grid()
return grid.fit_gauges if grid is not None else False
def _available_layout_width(parent: Tag | None, width: int) -> int:
if width > 0:
return width
if parent is not None and dpg.does_item_exist(parent):
return max(dpg.get_item_width(parent) or 0, 0)
try:
container = dpg.top_container_stack()
except Exception:
return 0
if container is not None and dpg.does_item_exist(container):
return max(dpg.get_item_width(container) or 0, 0)
return 0
def _resolved_columns(
columns: int | Literal["auto"], min_column_width: int, available_width: int
) -> int:
min_column_width = max(int(min_column_width), 0)
if columns == "auto":
if available_width > 0 and min_column_width > 0:
return max(1, available_width // min_column_width)
return 1
columns = max(int(columns), 1)
if available_width > 0 and min_column_width > 0:
columns = min(columns, max(1, available_width // min_column_width))
return columns
def gauge_panel(
*,
tag: Tag | None = None,
parent: Tag | None = None,
label: str | None = None,
width: int = 0,
height: int = 0,
@@ -166,8 +289,14 @@ def gauge_panel(
"""Create a lightweight child-window container for related gauges."""
if not _dpg_context_active:
return LayoutContext(None)
layout_parent, layout_width = _next_layout_cell(parent)
if layout_parent is not None:
parent = layout_parent
if width <= 0 and layout_width is not None:
width = layout_width
container = dpg.add_child_window(
tag=tag or 0,
parent=parent or 0,
label=label or "",
width=width,
height=height,
@@ -183,31 +312,56 @@ def gauge_panel(
def gauge_grid(
*,
columns: int = 1,
columns: int | Literal["auto"] = 1,
min_column_width: int = 180,
fit_gauges: bool = True,
cell_padding: int = 12,
row_height: int = 0,
tag: Tag | None = None,
parent: Tag | None = None,
width: int = 0,
height: int = 0,
show: bool = True,
**config: Any,
) -> LayoutContext:
"""Create a lightweight horizontal gauge layout container."""
) -> GridLayoutContext:
"""Create a lightweight wrapping gauge layout container."""
if not _dpg_context_active:
return LayoutContext(None)
columns = max(int(columns), 1)
container = dpg.add_group(
return GridLayoutContext(None, 1, min_column_width, fit_gauges, cell_padding)
layout_parent, layout_width = _next_layout_cell(parent)
if layout_parent is not None:
parent = layout_parent
if width <= 0 and layout_width is not None:
width = layout_width
available_width = _available_layout_width(parent, width)
columns = _resolved_columns(columns, min_column_width, available_width)
container = dpg.add_table(
tag=tag or 0,
parent=parent or 0,
width=width,
height=height,
show=show,
horizontal=columns > 1,
header_row=False,
policy=dpg.mvTable_SizingStretchSame,
scrollX=False,
**config,
)
return LayoutContext(container)
for _ in range(columns):
dpg.add_table_column(parent=container, width_stretch=True, init_width_or_weight=1.0)
return GridLayoutContext(
container,
columns,
min_column_width,
fit_gauges,
cell_padding,
row_height,
available_width,
)
def analog_gauge(
*,
tag: Tag | None = None,
parent: Tag | None = None,
label: str | None = None,
value: GaugeValue = None,
min_value: float = 0.0,
@@ -253,6 +407,7 @@ def analog_gauge(
def digital_gauge(
*,
tag: Tag | None = None,
parent: Tag | None = None,
label: str | None = None,
value: GaugeValue = None,
min_value: float = 0.0,
@@ -287,6 +442,7 @@ def digital_gauge(
def bar_gauge(
*,
tag: Tag | None = None,
parent: Tag | None = None,
label: str | None = None,
value: GaugeValue = None,
min_value: float = 0.0,
@@ -326,6 +482,7 @@ def bar_gauge(
def level_gauge(
*,
tag: Tag | None = None,
parent: Tag | None = None,
label: str | None = None,
value: GaugeValue = None,
min_value: float = 0.0,
@@ -365,6 +522,7 @@ def level_gauge(
def status_light(
*,
tag: Tag | None = None,
parent: Tag | None = None,
label: str | None = None,
value: GaugeValue = None,
min_value: float = 0.0,
@@ -403,6 +561,7 @@ def status_light(
def segmented_gauge(
*,
tag: Tag | None = None,
parent: Tag | None = None,
label: str | None = None,
value: GaugeValue = None,
min_value: float = 0.0,
@@ -443,6 +602,7 @@ def segmented_gauge(
def compass_gauge(
*,
tag: Tag | None = None,
parent: Tag | None = None,
label: str | None = None,
value: GaugeValue = None,
min_value: float = 0.0,
@@ -488,6 +648,7 @@ def compass_gauge(
def thermometer_gauge(
*,
tag: Tag | None = None,
parent: Tag | None = None,
label: str | None = None,
value: GaugeValue = None,
min_value: float = 0.0,
@@ -529,6 +690,7 @@ def thermometer_gauge(
def battery_gauge(
*,
tag: Tag | None = None,
parent: Tag | None = None,
label: str | None = None,
value: GaugeValue = None,
min_value: float = 0.0,
@@ -569,6 +731,7 @@ def battery_gauge(
def multi_value_gauge(
*,
tag: Tag | None = None,
parent: Tag | None = None,
label: str | None = None,
value: GaugeValue = None,
min_value: float = 0.0,

View File

@@ -1,6 +1,7 @@
# pyright: reportGeneralTypeIssues=false
from collections.abc import Generator
from typing import cast
import dearpygui.dearpygui as dpg
import pytest
@@ -9,6 +10,10 @@ import dpg_gauges as dpgg
from dpg_gauges.state import clear_registry, get_gauge_state
def _children(item: int | str) -> dict[int, list[int | str]]:
return cast(dict[int, list[int | str]], dpg.get_item_children(item))
@pytest.fixture
def dpg_context() -> Generator[None]:
clear_registry()
@@ -76,3 +81,56 @@ def test_panel_and_grid_are_lightweight_containers(dpg_context: None) -> None:
assert dpg.does_item_exist("engine")
assert dpg.does_item_exist("engine_grid")
assert get_gauge_state("temp").renderer is not None
def test_grid_columns_wrap_gauges_into_rows(dpg_context: None) -> None:
with dpg.window(label="Dashboard", width=720), dpgg.gauge_grid(
tag="wrap_grid", columns=3, min_column_width=1
):
for index in range(5):
dpgg.digital_gauge(tag=f"metric_{index}", value=index, height=60)
children = _children("wrap_grid")
assert len(children[0]) == 3
assert len(children[1]) == 2
assert [len(_children(row)[1]) for row in children[1]] == [3, 2]
def test_grid_auto_reduces_columns_to_parent_width(dpg_context: None) -> None:
with dpg.window(label="Narrow", width=380), dpgg.gauge_grid(
tag="narrow_grid", columns=3, min_column_width=180
):
for index in range(5):
dpgg.digital_gauge(tag=f"narrow_metric_{index}", value=index, height=60)
children = _children("narrow_grid")
assert len(children[0]) == 2
assert len(children[1]) == 3
assert [len(_children(row)[1]) for row in children[1]] == [2, 2, 1]
assert 0 < get_gauge_state("narrow_metric_0").config.width < 190
def test_grid_auto_columns_uses_available_width(dpg_context: None) -> None:
with dpg.window(label="Auto", width=640), dpgg.gauge_grid(
tag="auto_grid", columns="auto", min_column_width=180
):
for index in range(7):
dpgg.digital_gauge(tag=f"auto_metric_{index}", value=index, height=60)
children = _children("auto_grid")
assert len(children[0]) == 3
assert len(children[1]) == 3
assert [len(_children(row)[1]) for row in children[1]] == [3, 3, 1]
def test_grid_routes_panels_without_stealing_panel_children(dpg_context: None) -> None:
with dpg.window(label="Nested", width=520), dpgg.gauge_grid(tag="nested_grid", columns=2):
with dpgg.gauge_panel(tag="nested_panel", height=120):
dpgg.digital_gauge(tag="inside_panel", value=1, height=60)
dpgg.digital_gauge(tag="beside_panel", value=2, height=60)
panel_cell = cast(int | str, dpg.get_item_parent("nested_panel"))
assert dpg.get_item_type(panel_cell) == "mvAppItemType::mvTableCell"
assert dpg.get_item_parent("inside_panel") == "nested_panel"
beside_cell = cast(int | str, dpg.get_item_parent("beside_panel"))
assert dpg.get_item_type(beside_cell) == "mvAppItemType::mvTableCell"

View File

@@ -1,6 +1,7 @@
# pyright: reportGeneralTypeIssues=false
from collections.abc import Generator
from importlib.resources import files
import dearpygui.dearpygui as dpg
import pytest
@@ -30,11 +31,22 @@ def dpg_context() -> Generator[None]:
def test_public_exports_and_version() -> None:
assert dpgg.__version__ == "1.0.0"
assert dpgg.__version__ == "1.1.0"
assert files("dpg_gauges").joinpath("py.typed").is_file()
for name in dpgg.__all__:
assert hasattr(dpgg, name)
def test_gauge_creation_supports_explicit_parent(dpg_context: None) -> None:
dpg.add_window(tag="late_parent", label="Late Parent")
dpgg.analog_gauge(tag="late_gauge", parent="late_parent", width=180, height=180)
state = get_gauge_state("late_gauge")
assert state.config.parent == "late_parent"
assert dpg.get_item_parent(state.container_tag) == "late_parent"
def test_unknown_duplicate_and_deleted_gauge_errors() -> None:
with pytest.raises(GaugeNotFoundError):
dpgg.set_value("missing", 1)

2
uv.lock generated
View File

@@ -36,7 +36,7 @@ wheels = [
[[package]]
name = "dpg-gauges"
version = "1.0.0"
version = "1.1.0"
source = { editable = "." }
dependencies = [
{ name = "dearpygui" },