Improve gauge grid layout

This commit is contained in:
2026-07-07 23:00:49 +01:00
parent ceba71927d
commit 44403bea2b
6 changed files with 260 additions and 10 deletions

View File

@@ -37,12 +37,16 @@ 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. `columns` 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. 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,10 @@ 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, **config)`: table-backed
wrapping gauge grid. `columns` is the maximum number of columns. 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. Gauges without an explicit positive width fill their grid cell.
## 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`

64
examples/layout_grid.py Normal file
View File

@@ -0,0 +1,64 @@
# 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("Three-column grid: fixed column cap, automatic rows")
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("Narrow panel: columns=3 auto-reduces to avoid horizontal scrolling")
with dpgg.gauge_panel(label="Narrow panel", width=390, height=250), dpgg.gauge_grid(
columns=3, 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

@@ -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,69 @@ 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
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), 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 +222,49 @@ 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, min_column_width: int, available_width: int) -> int:
columns = max(int(columns), 1)
min_column_width = max(int(min_column_width), 0)
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 +282,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,
@@ -187,27 +306,42 @@ def gauge_panel(
def gauge_grid( def gauge_grid(
*, *,
columns: int = 1, columns: int = 1,
min_column_width: int = 180,
fit_gauges: bool = True,
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)
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, 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,42 @@ 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]
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"