From c95ed3a239dd89255f596e77809e64235e43f715 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Wed, 8 Jul 2026 11:35:40 +0100 Subject: [PATCH] Release 1.1.2 --- examples/reconnect_grid.py | 62 +++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/dpg_gauges/__init__.py | 2 +- src/dpg_gauges/renderer.py | 9 +++-- src/dpg_gauges/state.py | 7 +++- src/dpg_gauges/widget.py | 59 ++++++++++++++++++++++++++- tests/test_step3_widget_renderer.py | 52 ++++++++++++++++++++++++ tests/test_step7_hardening.py | 2 +- uv.lock | 2 +- 9 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 examples/reconnect_grid.py diff --git a/examples/reconnect_grid.py b/examples/reconnect_grid.py new file mode 100644 index 0000000..777e35c --- /dev/null +++ b/examples/reconnect_grid.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 3d2b9b1..39b3d51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dpg-gauges" -version = "1.1.1" +version = "1.1.2" description = "Dear PyGui gauge widgets for dashboards and telemetry displays" readme = "README.md" authors = [ diff --git a/src/dpg_gauges/__init__.py b/src/dpg_gauges/__init__.py index 213b31f..b97acc3 100644 --- a/src/dpg_gauges/__init__.py +++ b/src/dpg_gauges/__init__.py @@ -35,7 +35,7 @@ from .types import ( ThresholdBand, ) -__version__ = "1.1.1" +__version__ = "1.1.2" __all__ = [ "configure", diff --git a/src/dpg_gauges/renderer.py b/src/dpg_gauges/renderer.py index 695698b..3dc372f 100644 --- a/src/dpg_gauges/renderer.py +++ b/src/dpg_gauges/renderer.py @@ -62,9 +62,12 @@ class GaugeRenderer: if state.animation_state.active or state.smoothing_state.active: self.schedule_frame() - def delete(self) -> None: - if dpg.does_item_exist(self.state.container_tag): - dpg.delete_item(self.state.container_tag) + def delete(self) -> Tag | None: + if not dpg.does_item_exist(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: if self._scheduled: diff --git a/src/dpg_gauges/state.py b/src/dpg_gauges/state.py index ddd7a62..ed1a7a4 100644 --- a/src/dpg_gauges/state.py +++ b/src/dpg_gauges/state.py @@ -301,9 +301,14 @@ def set_runtime_show(tag: Tag, show: bool) -> None: def delete_runtime_gauge(tag: Tag) -> None: state = get_gauge_state(tag) + layout_parent: Tag | None = None if state.renderer is not None: - state.renderer.delete() + layout_parent = state.renderer.delete() 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: diff --git a/src/dpg_gauges/widget.py b/src/dpg_gauges/widget.py index eeb0acb..d6664a1 100644 --- a/src/dpg_gauges/widget.py +++ b/src/dpg_gauges/widget.py @@ -44,6 +44,7 @@ _original_create_context = dpg.create_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 @@ -66,6 +67,7 @@ def _install_context_tracking() -> None: _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] @@ -171,6 +173,13 @@ class GridLayoutContext: 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 @@ -337,6 +346,9 @@ def _reflow_table_cells(table: Tag, columns: int, row_height: int = 0) -> None: 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 @@ -376,10 +388,22 @@ def _table_cell_contents(table: Tag) -> list[list[Tag]]: 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, []))) + 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): @@ -397,6 +421,35 @@ def _last_row_state(table: Tag, columns: int) -> tuple[Tag | None, int]: 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: @@ -416,7 +469,11 @@ def _refresh_grid_layouts() -> None: 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() diff --git a/tests/test_step3_widget_renderer.py b/tests/test_step3_widget_renderer.py index 5f7c841..39248ae 100644 --- a/tests/test_step3_widget_renderer.py +++ b/tests/test_step3_widget_renderer.py @@ -14,6 +14,17 @@ 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 def dpg_context() -> Generator[None]: clear_registry() @@ -212,3 +223,44 @@ def test_auto_grid_reflows_existing_gauges_when_panel_narrows(dpg_context: None) 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] diff --git a/tests/test_step7_hardening.py b/tests/test_step7_hardening.py index 38d3ca1..2d4daf8 100644 --- a/tests/test_step7_hardening.py +++ b/tests/test_step7_hardening.py @@ -31,7 +31,7 @@ def dpg_context() -> Generator[None]: def test_public_exports_and_version() -> None: - assert dpgg.__version__ == "1.1.1" + assert dpgg.__version__ == "1.1.2" assert files("dpg_gauges").joinpath("py.typed").is_file() for name in dpgg.__all__: assert hasattr(dpgg, name) diff --git a/uv.lock b/uv.lock index 20d0d5b..f0b4f5b 100644 --- a/uv.lock +++ b/uv.lock @@ -36,7 +36,7 @@ wheels = [ [[package]] name = "dpg-gauges" -version = "1.1.1" +version = "1.1.2" source = { editable = "." } dependencies = [ { name = "dearpygui" },