3 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
10 changed files with 300 additions and 15 deletions

View File

@@ -37,12 +37,18 @@ dpg.destroy_context()
```python ```python
with dpg.window(label="Engine"): with dpg.window(label="Engine"):
with dpgg.gauge_panel(label="Powertrain", width=-1, height=260): 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.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.bar_gauge(tag="boost", label="Boost", max_value=30, unit="psi")
dpgg.status_light(tag="ecu", label="ECU", state=True) 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 Gauges also accept Dear PyGui's usual `parent` argument for adding a gauge to an
existing container after that container has already been created: existing container after that container has already been created:

View File

@@ -24,7 +24,14 @@ Creation functions are context managers and accept common options such as `tag`,
## Layout Helpers ## Layout Helpers
- `gauge_panel(**config)`: child-window panel for grouped gauges. - `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 ## Runtime API

View File

@@ -15,6 +15,7 @@ Run examples with `uv run python examples/<name>.py`.
- `thresholds_markers.py` - `thresholds_markers.py`
- `animations.py` - `animations.py`
- `dashboard.py` - `dashboard.py`
- `layout_grid.py`
- `sizing.py` - `sizing.py`
- `live_high_rate.py` - `live_high_rate.py`
- `live_worst_case.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] [project]
name = "dpg-gauges" name = "dpg-gauges"
version = "1.0.2" version = "1.1.0"
description = "Dear PyGui gauge widgets for dashboards and telemetry displays" description = "Dear PyGui gauge widgets for dashboards and telemetry displays"
readme = "README.md" readme = "README.md"
authors = [ authors = [

View File

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

View File

@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass, replace
from types import TracebackType from types import TracebackType
from typing import Any, Generic, Literal, TypeVar, cast from typing import Any, Generic, Literal, TypeVar, cast
@@ -42,6 +42,8 @@ ConfigT = TypeVar("ConfigT", bound=GaugeConfig)
_dpg_context_active = False _dpg_context_active = False
_original_create_context = dpg.create_context _original_create_context = dpg.create_context
_original_destroy_context = dpg.destroy_context _original_destroy_context = dpg.destroy_context
_active_grids: list[GridLayoutContext] = []
_layout_suspend_depth = 0
def _install_context_tracking() -> None: def _install_context_tracking() -> None:
@@ -73,10 +75,14 @@ _install_context_tracking()
@dataclass @dataclass
class GaugeContext(Generic[ConfigT]): class GaugeContext(Generic[ConfigT]):
state: GaugeState state: GaugeState
_suspended_layout: bool = False
def __enter__(self) -> ConfigT: def __enter__(self) -> ConfigT:
global _layout_suspend_depth
if _dpg_context_active and dpg.does_item_exist(self.state.container_tag): if _dpg_context_active and dpg.does_item_exist(self.state.container_tag):
dpg.push_container_stack(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) return cast(ConfigT, self.state.config)
def __exit__( def __exit__(
@@ -85,17 +91,27 @@ class GaugeContext(Generic[ConfigT]):
exc: BaseException | None, exc: BaseException | None,
traceback: TracebackType | None, traceback: TracebackType | None,
) -> None: ) -> None:
global _layout_suspend_depth
if _dpg_context_active and dpg.does_item_exist(self.state.container_tag): if _dpg_context_active and dpg.does_item_exist(self.state.container_tag):
dpg.pop_container_stack() dpg.pop_container_stack()
if self._suspended_layout:
_layout_suspend_depth = max(_layout_suspend_depth - 1, 0)
self._suspended_layout = False
@dataclass @dataclass
class LayoutContext: class LayoutContext:
tag: Tag | None tag: Tag | None
suspend_layout: bool = True
_suspended_layout: bool = False
def __enter__(self) -> Tag | None: 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): if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag):
dpg.push_container_stack(self.tag) dpg.push_container_stack(self.tag)
if self.suspend_layout:
_layout_suspend_depth += 1
self._suspended_layout = True
return self.tag return self.tag
def __exit__( def __exit__(
@@ -104,14 +120,70 @@ class LayoutContext:
exc: BaseException | None, exc: BaseException | None,
traceback: TracebackType | None, traceback: TracebackType | None,
) -> None: ) -> None:
global _layout_suspend_depth
if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag): if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag):
dpg.pop_container_stack() 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]: def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[ConfigT]:
if not _dpg_context_active: if not _dpg_context_active:
return GaugeContext(register_gauge(gauge_type, config)) 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 tag = config.tag
container_tag = tag if tag is not None else None container_tag = tag if tag is not None else None
drawlist_tag = f"{tag}__drawlist" if tag is not None else None drawlist_tag = f"{tag}__drawlist" if tag is not None else None
@@ -151,6 +223,55 @@ def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[Confi
return GaugeContext(state) 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( def gauge_panel(
*, *,
tag: Tag | None = None, tag: Tag | None = None,
@@ -168,6 +289,11 @@ def gauge_panel(
"""Create a lightweight child-window container for related gauges.""" """Create a lightweight child-window container for related gauges."""
if not _dpg_context_active: if not _dpg_context_active:
return LayoutContext(None) 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( container = dpg.add_child_window(
tag=tag or 0, tag=tag or 0,
parent=parent or 0, parent=parent or 0,
@@ -186,28 +312,50 @@ def gauge_panel(
def gauge_grid( 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, tag: Tag | None = None,
parent: Tag | None = None, parent: Tag | None = None,
width: int = 0, width: int = 0,
height: int = 0, height: int = 0,
show: bool = True, show: bool = True,
**config: Any, **config: Any,
) -> LayoutContext: ) -> GridLayoutContext:
"""Create a lightweight horizontal gauge layout container.""" """Create a lightweight wrapping gauge layout container."""
if not _dpg_context_active: if not _dpg_context_active:
return LayoutContext(None) return GridLayoutContext(None, 1, min_column_width, fit_gauges, cell_padding)
columns = max(int(columns), 1) layout_parent, layout_width = _next_layout_cell(parent)
container = dpg.add_group( 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, tag=tag or 0,
parent=parent or 0, parent=parent or 0,
width=width, width=width,
height=height, height=height,
show=show, show=show,
horizontal=columns > 1, header_row=False,
policy=dpg.mvTable_SizingStretchSame,
scrollX=False,
**config, **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( def analog_gauge(

View File

@@ -1,6 +1,7 @@
# pyright: reportGeneralTypeIssues=false # pyright: reportGeneralTypeIssues=false
from collections.abc import Generator from collections.abc import Generator
from typing import cast
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
import pytest import pytest
@@ -9,6 +10,10 @@ import dpg_gauges as dpgg
from dpg_gauges.state import clear_registry, get_gauge_state 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 @pytest.fixture
def dpg_context() -> Generator[None]: def dpg_context() -> Generator[None]:
clear_registry() 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")
assert dpg.does_item_exist("engine_grid") assert dpg.does_item_exist("engine_grid")
assert get_gauge_state("temp").renderer is not None 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

@@ -31,7 +31,7 @@ def dpg_context() -> Generator[None]:
def test_public_exports_and_version() -> None: def test_public_exports_and_version() -> None:
assert dpgg.__version__ == "1.0.2" assert dpgg.__version__ == "1.1.0"
assert files("dpg_gauges").joinpath("py.typed").is_file() assert files("dpg_gauges").joinpath("py.typed").is_file()
for name in dpgg.__all__: for name in dpgg.__all__:
assert hasattr(dpgg, name) assert hasattr(dpgg, name)

2
uv.lock generated
View File

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