5 Commits

Author SHA1 Message Date
c95ed3a239 Release 1.1.2 2026-07-08 11:35:40 +01:00
5b1826d9c3 Release 1.1.1 2026-07-08 11:06:17 +01:00
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
14 changed files with 834 additions and 20 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`

View File

@@ -0,0 +1,91 @@
# pyright: reportGeneralTypeIssues=false
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
COUNTERS: dict[str, int] = {}
def add_runtime_gauges(grid: str, prefix: str, count: int = 6) -> None:
start = COUNTERS.get(grid, 0)
for offset in range(count):
index = start + offset
value = (index * 17 + 23) % 101
dpgg.digital_gauge(
tag=f"{prefix}_{index}",
parent=grid,
label=f"{prefix} {index + 1}",
value=value,
unit="%",
height=72,
)
COUNTERS[grid] = start + count
def main() -> None:
dpg.create_context()
try:
with dpg.window(label="Runtime autobreak tests", width=1220, height=900):
dpg.add_text("Fixed panel, runtime gauges: expected 5 columns at width=1000")
with dpgg.gauge_panel(label="Fixed 1000 x 230", width=1000, height=230):
dpgg.gauge_grid(tag="fixed_runtime_grid", columns="auto", min_column_width=180)
dpg.add_button(
label="Add fixed gauges",
callback=lambda: add_runtime_gauges("fixed_runtime_grid", "fixed"),
)
dpg.add_spacer(height=10)
dpg.add_text("Autosize panel with configured size: should match fixed panel")
with dpgg.gauge_panel(
label="Autosize configured 1000 x 230",
width=1000,
height=230,
autosize_x=True,
autosize_y=True,
):
dpgg.gauge_grid(tag="autosize_runtime_grid", columns="auto", min_column_width=180)
dpg.add_button(
label="Add autosize gauges",
callback=lambda: add_runtime_gauges("autosize_runtime_grid", "auto"),
)
dpg.add_spacer(height=10)
dpg.add_text("Autosize panel without width: grid falls back to the owning window width")
with dpgg.gauge_panel(label="Autosize inherited width", height=230, autosize_x=True):
dpgg.gauge_grid(tag="inherited_runtime_grid", columns="auto", min_column_width=180)
dpg.add_button(
label="Add inherited-width gauges",
callback=lambda: add_runtime_gauges("inherited_runtime_grid", "inherited"),
)
dpg.add_spacer(height=10)
dpg.add_text("Narrow autosize panel: expected 2 columns at width=430")
with dpgg.gauge_panel(
label="Narrow autosize 430 x 210",
width=430,
height=210,
autosize_x=True,
autosize_y=True,
):
dpgg.gauge_grid(tag="narrow_runtime_grid", columns="auto", min_column_width=180)
dpg.add_button(
label="Add narrow gauges",
callback=lambda: add_runtime_gauges("narrow_runtime_grid", "narrow"),
)
dpg.set_frame_callback(2, lambda: add_runtime_gauges("fixed_runtime_grid", "fixed"))
dpg.set_frame_callback(3, lambda: add_runtime_gauges("autosize_runtime_grid", "auto"))
dpg.set_frame_callback(4, lambda: add_runtime_gauges("inherited_runtime_grid", "inherited"))
dpg.set_frame_callback(5, lambda: add_runtime_gauges("narrow_runtime_grid", "narrow"))
dpg.create_viewport(title="dpg-gauges runtime autobreak", width=1260, height=940)
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.start_dearpygui()
finally:
dpg.destroy_context()
if __name__ == "__main__":
main()

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

@@ -0,0 +1,62 @@
# pyright: reportGeneralTypeIssues=false
import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg
GRID = "connector_grid"
ACTIVE_TAGS: list[str] = []
CYCLE = 0
def connect_connector() -> None:
global CYCLE
if ACTIVE_TAGS:
return
for index in range(5):
tag = f"connector_{CYCLE}_{index}"
ACTIVE_TAGS.append(tag)
dpgg.digital_gauge(
tag=tag,
parent=GRID,
label=f"Conn {CYCLE}.{index}",
value=(CYCLE * 13 + index * 17) % 100,
unit="%",
height=72,
)
CYCLE += 1
def disconnect_connector() -> None:
while ACTIVE_TAGS:
dpgg.delete_gauge(ACTIVE_TAGS.pop())
def reconnect_connector() -> None:
disconnect_connector()
connect_connector()
def main() -> None:
dpg.create_context()
try:
with dpg.window(label="Reconnect grid test", width=860, height=420):
dpg.add_text("Reconnect should always start at the first grid cell.")
with dpgg.gauge_panel(label="Connector gauges", width=820, height=230):
dpgg.gauge_grid(tag=GRID, columns="auto", min_column_width=180)
with dpg.group(horizontal=True):
dpg.add_button(label="Connect", callback=connect_connector)
dpg.add_button(label="Disconnect", callback=disconnect_connector)
dpg.add_button(label="Reconnect", callback=reconnect_connector)
dpg.set_frame_callback(2, connect_connector)
dpg.create_viewport(title="dpg-gauges reconnect grid", width=900, height=460)
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.2"
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.2"
__all__ = [ __all__ = [
"configure", "configure",

View File

@@ -62,9 +62,12 @@ class GaugeRenderer:
if state.animation_state.active or state.smoothing_state.active: if state.animation_state.active or state.smoothing_state.active:
self.schedule_frame() self.schedule_frame()
def delete(self) -> None: def delete(self) -> Tag | None:
if dpg.does_item_exist(self.state.container_tag): if not dpg.does_item_exist(self.state.container_tag):
dpg.delete_item(self.state.container_tag) return None
parent = dpg.get_item_parent(self.state.container_tag)
dpg.delete_item(self.state.container_tag)
return cast(Tag | None, parent)
def schedule_frame(self) -> None: def schedule_frame(self) -> None:
if self._scheduled: if self._scheduled:

View File

@@ -301,9 +301,14 @@ def set_runtime_show(tag: Tag, show: bool) -> None:
def delete_runtime_gauge(tag: Tag) -> None: def delete_runtime_gauge(tag: Tag) -> None:
state = get_gauge_state(tag) state = get_gauge_state(tag)
layout_parent: Tag | None = None
if state.renderer is not None: if state.renderer is not None:
state.renderer.delete() layout_parent = state.renderer.delete()
del _registry[tag] del _registry[tag]
if layout_parent is not None:
from .widget import _cleanup_grid_after_item_delete
_cleanup_grid_after_item_delete(layout_parent)
def render_runtime_gauge(state: GaugeState) -> None: def render_runtime_gauge(state: GaugeState) -> None:

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,11 @@ 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] = []
_grid_layouts: dict[Tag, GridLayoutContext] = {}
_pending_grid_cleanups: set[Tag] = set()
_layout_suspend_depth = 0
_grid_refresh_scheduled = False
def _install_context_tracking() -> None: def _install_context_tracking() -> None:
@@ -55,11 +60,15 @@ def _install_context_tracking() -> None:
return result return result
def destroy_context(*args: Any, **kwargs: Any) -> Any: def destroy_context(*args: Any, **kwargs: Any) -> Any:
global _dpg_context_active global _dpg_context_active, _grid_refresh_scheduled
try: try:
return _original_destroy_context(*args, **kwargs) return _original_destroy_context(*args, **kwargs)
finally: finally:
_dpg_context_active = False _dpg_context_active = False
_active_grids.clear()
_grid_layouts.clear()
_pending_grid_cleanups.clear()
_grid_refresh_scheduled = False
create_context.__dpg_gauges_wrapped__ = True # type: ignore[attr-defined] create_context.__dpg_gauges_wrapped__ = True # type: ignore[attr-defined]
destroy_context.__dpg_gauges_wrapped__ = True # type: ignore[attr-defined] destroy_context.__dpg_gauges_wrapped__ = True # type: ignore[attr-defined]
@@ -73,10 +82,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 +98,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 +127,104 @@ 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
parent: Tag | None
width: int
requested_columns: int | Literal["auto"]
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
_current_row_cells: int = 0
_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
self.refresh_columns()
empty_cell = _first_empty_table_cell(self.tag)
if empty_cell is not None:
self._next_index = _table_cell_count(self.tag)
self._current_row, self._current_row_cells = _last_row_state(
self.tag, self.columns
)
return empty_cell, self.cell_width()
if self._current_row is None or self._current_row_cells >= self.columns:
self._current_row = dpg.add_table_row(parent=self.tag, height=self.row_height)
self._current_row_cells = 0
cell = dpg.add_table_cell(parent=self._current_row or self.tag)
self._next_index += 1
self._current_row_cells += 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 refresh_columns(self) -> None:
if self.tag is None or not _dpg_context_active or not dpg.does_item_exist(self.tag):
return
available_width = _available_layout_width(self.parent, self.width)
if available_width <= 0:
return
columns = _resolved_columns(self.requested_columns, self.min_column_width, available_width)
if columns == self.columns and available_width == self.available_width:
return
_reflow_table_cells(self.tag, columns, self.row_height)
self.columns = columns
self.available_width = available_width
self._next_index = _table_cell_count(self.tag)
self._current_row, self._current_row_cells = _last_row_state(self.tag, self.columns)
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, layout_grid = _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 layout_grid is not None
and layout_grid.fit_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 +264,235 @@ 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, GridLayoutContext | None]:
if parent is not None:
grid = _grid_layouts.get(parent)
if grid is not None:
cell, width = grid.next_cell()
return cell, width, grid
return None, None, None
grid = _current_grid()
if grid is None:
return None, None, None
cell, width = grid.next_cell()
return cell, width, grid
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 _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 _available_layout_width_for_item(parent)
try:
container = dpg.top_container_stack()
except Exception:
return 0
if container is not None and dpg.does_item_exist(container):
return _available_layout_width_for_item(container)
return 0
def _available_layout_width_for_item(item: Tag) -> int:
while item is not None and dpg.does_item_exist(item):
width = _item_layout_width(item)
if width > 0:
return width
parent = dpg.get_item_parent(item)
if parent is None or parent == item:
break
item = parent
return 0
def _item_layout_width(item: Tag) -> int:
width = max(dpg.get_item_width(item) or 0, 0)
if width > 0:
return width
try:
configured_width = dpg.get_item_configuration(item).get("width", 0)
except Exception:
return 0
if isinstance(configured_width, int | float) and configured_width > 0:
return int(configured_width)
return 0
def _set_table_column_count(table: Tag, columns: int) -> None:
children = cast(dict[int, list[Tag]], dpg.get_item_children(table) or {})
table_columns = list(children.get(0, []))
current = len(table_columns)
if columns > current:
for _ in range(columns - current):
dpg.add_table_column(parent=table, width_stretch=True, init_width_or_weight=1.0)
elif columns < current:
for column in reversed(table_columns[columns:]):
dpg.delete_item(column)
def _reflow_table_cells(table: Tag, columns: int, row_height: int = 0) -> None:
columns = max(int(columns), 1)
cell_contents = _table_cell_contents(table)
old_rows = _table_rows(table)
if not cell_contents:
for row in old_rows:
if dpg.does_item_exist(row):
dpg.delete_item(row)
_set_table_column_count(table, columns)
return
if columns > len(_table_columns(table)):
_set_table_column_count(table, columns)
new_rows: list[Tag] = []
for index, contents in enumerate(cell_contents):
if index % columns == 0:
new_rows.append(dpg.add_table_row(parent=table, height=row_height))
cell = dpg.add_table_cell(parent=new_rows[-1])
for child in contents:
if dpg.does_item_exist(child):
dpg.move_item(child, parent=cell)
for row in old_rows:
if dpg.does_item_exist(row):
dpg.delete_item(row)
if columns < len(_table_columns(table)):
_set_table_column_count(table, columns)
def _table_columns(table: Tag) -> list[Tag]:
children = cast(dict[int, list[Tag]], dpg.get_item_children(table) or {})
return list(children.get(0, []))
def _table_rows(table: Tag) -> list[Tag]:
children = cast(dict[int, list[Tag]], dpg.get_item_children(table) or {})
return list(children.get(1, []))
def _table_cell_contents(table: Tag) -> list[list[Tag]]:
cells: list[list[Tag]] = []
for row in _table_rows(table):
row_children = cast(dict[int, list[Tag]], dpg.get_item_children(row) or {})
for cell in row_children.get(1, []):
cell_children = cast(dict[int, list[Tag]], dpg.get_item_children(cell) or {})
contents = [child for child in cell_children.get(1, []) if dpg.does_item_exist(child)]
if contents:
cells.append(contents)
return cells
def _first_empty_table_cell(table: Tag) -> Tag | None:
for row in _table_rows(table):
row_children = cast(dict[int, list[Tag]], dpg.get_item_children(row) or {})
for cell in row_children.get(1, []):
cell_children = cast(dict[int, list[Tag]], dpg.get_item_children(cell) or {})
if not any(dpg.does_item_exist(child) for child in cell_children.get(1, [])):
return cell
return None
def _table_cell_count(table: Tag) -> int:
count = 0
for row in _table_rows(table):
row_children = cast(dict[int, list[Tag]], dpg.get_item_children(row) or {})
count += len(row_children.get(1, []))
return count
def _last_row_state(table: Tag, columns: int) -> tuple[Tag | None, int]:
rows = _table_rows(table)
if not rows:
return None, 0
row = rows[-1]
row_children = cast(dict[int, list[Tag]], dpg.get_item_children(row) or {})
return row, min(len(row_children.get(1, [])), columns)
def _cleanup_grid_after_item_delete(parent: Tag) -> None:
if not _dpg_context_active or not dpg.does_item_exist(parent):
return
row = dpg.get_item_parent(parent)
if row is None or not dpg.does_item_exist(row):
return
table = dpg.get_item_parent(row)
if table is None or table not in _grid_layouts or not dpg.does_item_exist(table):
return
_pending_grid_cleanups.add(table)
_schedule_grid_refresh()
def _compact_grid(grid: GridLayoutContext) -> None:
table = grid.tag
if table is None or not dpg.does_item_exist(table):
return
_reflow_table_cells(table, grid.columns, grid.row_height)
grid._next_index = _table_cell_count(table)
grid._current_row, grid._current_row_cells = _last_row_state(table, grid.columns)
def _cleanup_grid_now(table: Tag) -> None:
if table not in _grid_layouts or not dpg.does_item_exist(table):
return
grid = _grid_layouts[table]
_compact_grid(grid)
def _schedule_grid_refresh() -> None:
global _grid_refresh_scheduled
if _grid_refresh_scheduled or not _dpg_context_active:
return
try:
dpg.set_frame_callback(dpg.get_frame_count() + 1, _refresh_grid_layouts)
except Exception:
return
_grid_refresh_scheduled = True
def _refresh_grid_layouts() -> None:
global _grid_refresh_scheduled
_grid_refresh_scheduled = False
if not _dpg_context_active:
return
for tag, grid in list(_grid_layouts.items()):
if tag is None or not dpg.does_item_exist(tag):
del _grid_layouts[tag]
_pending_grid_cleanups.discard(tag)
continue
if tag in _pending_grid_cleanups:
_pending_grid_cleanups.discard(tag)
_compact_grid(grid)
grid.refresh_columns()
if _grid_layouts:
_schedule_grid_refresh()
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 +510,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, _layout_grid = _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 +533,67 @@ 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(
columns = max(int(columns), 1) tag=None,
container = dpg.add_group( parent=None,
width=width,
requested_columns=columns,
columns=1,
min_column_width=min_column_width,
fit_gauges=fit_gauges,
cell_padding=cell_padding,
)
layout_parent, layout_width, _layout_grid = _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)
requested_columns = columns
resolved_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(resolved_columns):
dpg.add_table_column(parent=container, width_stretch=True, init_width_or_weight=1.0)
resolved_parent = cast(Tag | None, dpg.get_item_parent(container))
context = GridLayoutContext(
tag=container,
parent=resolved_parent,
width=width,
requested_columns=requested_columns,
columns=resolved_columns,
min_column_width=min_column_width,
fit_gauges=fit_gauges,
cell_padding=cell_padding,
row_height=row_height,
available_width=available_width,
)
_grid_layouts[container] = context
_schedule_grid_refresh()
return context
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,21 @@ 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))
def _occupied_cell_counts(table: int | str) -> list[int]:
counts: list[int] = []
for row in _children(table)[1]:
occupied = 0
for cell in _children(row)[1]:
if any(dpg.does_item_exist(child) for child in _children(cell)[1]):
occupied += 1
counts.append(occupied)
return counts
@pytest.fixture @pytest.fixture
def dpg_context() -> Generator[None]: def dpg_context() -> Generator[None]:
clear_registry() clear_registry()
@@ -76,3 +92,175 @@ 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"
def test_grid_accepts_gauges_added_after_context(dpg_context: None) -> None:
with dpg.window(label="Late", width=520), dpgg.gauge_grid(tag="late_grid", columns=2):
dpgg.digital_gauge(tag="late_initial", value=1, height=60)
dpgg.digital_gauge(tag="late_second", parent="late_grid", value=2, height=60)
dpgg.digital_gauge(tag="late_third", parent="late_grid", value=3, height=60)
children = _children("late_grid")
assert [len(_children(row)[1]) for row in children[1]] == [2, 1]
for tag in ("late_initial", "late_second", "late_third"):
cell = cast(int | str, dpg.get_item_parent(tag))
assert dpg.get_item_type(cell) == "mvAppItemType::mvTableCell"
def test_auto_grid_in_autosize_panel_wraps_late_gauges(dpg_context: None) -> None:
with (
dpg.window(label="Autosize", width=1200),
dpgg.gauge_panel(
tag="autosize_panel", width=1000, height=600, autosize_x=True, autosize_y=True
),
):
dpgg.gauge_grid(tag="autosize_grid", columns="auto", min_column_width=180)
for index in range(7):
dpgg.digital_gauge(
tag=f"autosize_metric_{index}", parent="autosize_grid", value=index, height=60
)
children = _children("autosize_grid")
assert len(children[0]) == 5
assert [len(_children(row)[1]) for row in children[1]] == [5, 2]
assert 0 < get_gauge_state("autosize_metric_0").config.width < 200
def test_auto_grid_refreshes_when_autosize_width_becomes_known(dpg_context: None) -> None:
with (
dpg.window(label="Autosize"),
dpgg.gauge_panel(tag="late_width_panel", autosize_x=True, autosize_y=True),
):
grid = dpgg.gauge_grid(tag="late_width_grid", columns="auto", min_column_width=180)
assert grid.columns == 1
dpg.configure_item("late_width_panel", width=1000)
for index in range(6):
dpgg.digital_gauge(tag=f"late_width_metric_{index}", parent="late_width_grid", value=index)
children = _children("late_width_grid")
assert len(children[0]) == 5
assert [len(_children(row)[1]) for row in children[1]] == [5, 1]
def test_auto_grid_reflows_existing_gauges_when_panel_narrows(dpg_context: None) -> None:
with dpg.window(label="Resize"), dpgg.gauge_panel(
tag="resize_panel", width=1000, height=300
):
grid = dpgg.gauge_grid(tag="resize_grid", columns="auto", min_column_width=180)
with grid:
for index in range(6):
dpgg.digital_gauge(tag=f"resize_metric_{index}", value=index, height=60)
assert grid.columns == 5
children = _children("resize_grid")
assert [len(_children(row)[1]) for row in children[1]] == [5, 1]
dpg.configure_item("resize_panel", width=430)
grid.refresh_columns()
assert grid.columns == 2
children = _children("resize_grid")
assert len(children[0]) == 2
assert [len(_children(row)[1]) for row in children[1]] == [2, 2, 2]
for index in range(6):
cell = cast(int | str, dpg.get_item_parent(f"resize_metric_{index}"))
assert dpg.get_item_type(cell) == "mvAppItemType::mvTableCell"
def test_grid_compacts_after_runtime_gauge_delete(dpg_context: None) -> None:
from dpg_gauges.widget import _cleanup_grid_now
with dpg.window(label="Reconnect", width=800):
dpgg.gauge_grid(tag="connector_grid", columns=3, min_column_width=1)
for cycle in range(3):
tags = [f"connector_{cycle}_{index}" for index in range(3)]
for index, tag in enumerate(tags):
dpgg.digital_gauge(tag=tag, parent="connector_grid", value=index, height=60)
children = _children("connector_grid")
assert [len(_children(row)[1]) for row in children[1]] == [3]
assert _occupied_cell_counts("connector_grid") == [3]
for tag in tags:
dpgg.delete_gauge(tag)
_cleanup_grid_now("connector_grid")
children = _children("connector_grid")
assert children[1] == []
def test_grid_reuses_deleted_cell_before_appending(dpg_context: None) -> None:
with dpg.window(label="Partial", width=800):
dpgg.gauge_grid(tag="partial_grid", columns=3, min_column_width=1)
for index in range(3):
dpgg.digital_gauge(tag=f"partial_{index}", parent="partial_grid", value=index, height=60)
dpgg.delete_gauge("partial_1")
assert _occupied_cell_counts("partial_grid") == [2]
dpgg.digital_gauge(tag="partial_new", parent="partial_grid", value=99, height=60)
children = _children("partial_grid")
assert [len(_children(row)[1]) for row in children[1]] == [3]
assert _occupied_cell_counts("partial_grid") == [3]

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.2"
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.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dearpygui" }, { name = "dearpygui" },