Compare commits

2 Commits

Author SHA1 Message Date
a8a6e1db19 Merge dev for 1.1.1 release 2026-07-08 11:06:38 +01:00
5b1826d9c3 Release 1.1.1 2026-07-08 11:06:17 +01:00
7 changed files with 382 additions and 32 deletions

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()

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "dpg-gauges" name = "dpg-gauges"
version = "1.1.0" version = "1.1.1"
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.1.0" __version__ = "1.1.1"
__all__ = [ __all__ = [
"configure", "configure",

View File

@@ -43,7 +43,9 @@ _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] = [] _active_grids: list[GridLayoutContext] = []
_grid_layouts: dict[Tag, GridLayoutContext] = {}
_layout_suspend_depth = 0 _layout_suspend_depth = 0
_grid_refresh_scheduled = False
def _install_context_tracking() -> None: def _install_context_tracking() -> None:
@@ -57,11 +59,14 @@ 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()
_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]
@@ -131,6 +136,9 @@ class LayoutContext:
@dataclass @dataclass
class GridLayoutContext: class GridLayoutContext:
tag: Tag | None tag: Tag | None
parent: Tag | None
width: int
requested_columns: int | Literal["auto"]
columns: int columns: int
min_column_width: int min_column_width: int
fit_gauges: bool fit_gauges: bool
@@ -139,6 +147,7 @@ class GridLayoutContext:
available_width: int = 0 available_width: int = 0
_next_index: int = 0 _next_index: int = 0
_current_row: Tag | None = None _current_row: Tag | None = None
_current_row_cells: int = 0
_suspend_depth: int = 0 _suspend_depth: int = 0
def __enter__(self) -> Tag | None: def __enter__(self) -> Tag | None:
@@ -161,10 +170,13 @@ class GridLayoutContext:
def next_cell(self) -> tuple[Tag | None, int | None]: 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): if self.tag is None or not _dpg_context_active or not dpg.does_item_exist(self.tag):
return None, None return None, None
if self._next_index % self.columns == 0: self.refresh_columns()
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 = 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) cell = dpg.add_table_cell(parent=self._current_row or self.tag)
self._next_index += 1 self._next_index += 1
self._current_row_cells += 1
return cell, self.cell_width() return cell, self.cell_width()
def cell_width(self) -> int | None: def cell_width(self) -> int | None:
@@ -172,15 +184,35 @@ class GridLayoutContext:
return None return None
return max(int(self.available_width / self.columns) - self.cell_padding, 1) 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 = _next_layout_cell(config.parent) layout_parent, layout_width, layout_grid = _next_layout_cell(config.parent)
if layout_parent is not None: if layout_parent is not None:
updates: dict[str, Any] = {"parent": layout_parent} updates: dict[str, Any] = {"parent": layout_parent}
if layout_width is not None and _current_grid_fits_gauges() and config.width <= 0: 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 updates["width"] = layout_width
config = replace(config, **updates) config = replace(config, **updates)
@@ -223,11 +255,21 @@ 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]: 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() grid = _current_grid()
if parent is not None or grid is None: if grid is None:
return None, None return None, None, None
return grid.next_cell() cell, width = grid.next_cell()
return cell, width, grid
def _current_grid() -> GridLayoutContext | None: def _current_grid() -> GridLayoutContext | None:
@@ -239,25 +281,147 @@ def _current_grid() -> GridLayoutContext | None:
return grid 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: def _available_layout_width(parent: Tag | None, width: int) -> int:
if width > 0: if width > 0:
return width return width
if parent is not None and dpg.does_item_exist(parent): if parent is not None and dpg.does_item_exist(parent):
return max(dpg.get_item_width(parent) or 0, 0) return _available_layout_width_for_item(parent)
try: try:
container = dpg.top_container_stack() container = dpg.top_container_stack()
except Exception: except Exception:
return 0 return 0
if container is not None and dpg.does_item_exist(container): if container is not None and dpg.does_item_exist(container):
return max(dpg.get_item_width(container) or 0, 0) return _available_layout_width_for_item(container)
return 0 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:
_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 {})
cells.append(list(cell_children.get(1, [])))
return cells
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 _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]
continue
grid.refresh_columns()
if _grid_layouts:
_schedule_grid_refresh()
def _resolved_columns( def _resolved_columns(
columns: int | Literal["auto"], min_column_width: int, available_width: int columns: int | Literal["auto"], min_column_width: int, available_width: int
) -> int: ) -> int:
@@ -289,7 +453,7 @@ 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) layout_parent, layout_width, _layout_grid = _next_layout_cell(parent)
if layout_parent is not None: if layout_parent is not None:
parent = layout_parent parent = layout_parent
if width <= 0 and layout_width is not None: if width <= 0 and layout_width is not None:
@@ -326,14 +490,24 @@ def gauge_grid(
) -> GridLayoutContext: ) -> GridLayoutContext:
"""Create a lightweight wrapping gauge layout container.""" """Create a lightweight wrapping gauge layout container."""
if not _dpg_context_active: if not _dpg_context_active:
return GridLayoutContext(None, 1, min_column_width, fit_gauges, cell_padding) return GridLayoutContext(
layout_parent, layout_width = _next_layout_cell(parent) tag=None,
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: if layout_parent is not None:
parent = layout_parent parent = layout_parent
if width <= 0 and layout_width is not None: if width <= 0 and layout_width is not None:
width = layout_width width = layout_width
available_width = _available_layout_width(parent, width) available_width = _available_layout_width(parent, width)
columns = _resolved_columns(columns, min_column_width, available_width) requested_columns = columns
resolved_columns = _resolved_columns(columns, min_column_width, available_width)
container = dpg.add_table( container = dpg.add_table(
tag=tag or 0, tag=tag or 0,
parent=parent or 0, parent=parent or 0,
@@ -345,17 +519,24 @@ def gauge_grid(
scrollX=False, scrollX=False,
**config, **config,
) )
for _ in range(columns): for _ in range(resolved_columns):
dpg.add_table_column(parent=container, width_stretch=True, init_width_or_weight=1.0) dpg.add_table_column(parent=container, width_stretch=True, init_width_or_weight=1.0)
return GridLayoutContext( resolved_parent = cast(Tag | None, dpg.get_item_parent(container))
container, context = GridLayoutContext(
columns, tag=container,
min_column_width, parent=resolved_parent,
fit_gauges, width=width,
cell_padding, requested_columns=requested_columns,
row_height, columns=resolved_columns,
available_width, 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

@@ -134,3 +134,81 @@ def test_grid_routes_panels_without_stealing_panel_children(dpg_context: None) -
assert dpg.get_item_parent("inside_panel") == "nested_panel" assert dpg.get_item_parent("inside_panel") == "nested_panel"
beside_cell = cast(int | str, dpg.get_item_parent("beside_panel")) beside_cell = cast(int | str, dpg.get_item_parent("beside_panel"))
assert dpg.get_item_type(beside_cell) == "mvAppItemType::mvTableCell" 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"

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.1.0" assert dpgg.__version__ == "1.1.1"
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.1.0" version = "1.1.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dearpygui" }, { name = "dearpygui" },