From 5b1826d9c3d4a0beb436961b509d5d4fe5904fb1 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Wed, 8 Jul 2026 11:06:17 +0100 Subject: [PATCH] Release 1.1.1 --- examples/autobreak_runtime.py | 91 +++++++++++ pyproject.toml | 2 +- src/dpg_gauges/__init__.py | 2 +- src/dpg_gauges/widget.py | 237 ++++++++++++++++++++++++---- tests/test_step3_widget_renderer.py | 78 +++++++++ tests/test_step7_hardening.py | 2 +- uv.lock | 2 +- 7 files changed, 382 insertions(+), 32 deletions(-) create mode 100644 examples/autobreak_runtime.py diff --git a/examples/autobreak_runtime.py b/examples/autobreak_runtime.py new file mode 100644 index 0000000..703eaee --- /dev/null +++ b/examples/autobreak_runtime.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index bb78a23..3d2b9b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dpg-gauges" -version = "1.1.0" +version = "1.1.1" 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 6b85cd9..213b31f 100644 --- a/src/dpg_gauges/__init__.py +++ b/src/dpg_gauges/__init__.py @@ -35,7 +35,7 @@ from .types import ( ThresholdBand, ) -__version__ = "1.1.0" +__version__ = "1.1.1" __all__ = [ "configure", diff --git a/src/dpg_gauges/widget.py b/src/dpg_gauges/widget.py index e920512..eeb0acb 100644 --- a/src/dpg_gauges/widget.py +++ b/src/dpg_gauges/widget.py @@ -43,7 +43,9 @@ _dpg_context_active = False _original_create_context = dpg.create_context _original_destroy_context = dpg.destroy_context _active_grids: list[GridLayoutContext] = [] +_grid_layouts: dict[Tag, GridLayoutContext] = {} _layout_suspend_depth = 0 +_grid_refresh_scheduled = False def _install_context_tracking() -> None: @@ -57,11 +59,14 @@ def _install_context_tracking() -> None: return result def destroy_context(*args: Any, **kwargs: Any) -> Any: - global _dpg_context_active + global _dpg_context_active, _grid_refresh_scheduled try: return _original_destroy_context(*args, **kwargs) finally: _dpg_context_active = False + _active_grids.clear() + _grid_layouts.clear() + _grid_refresh_scheduled = False create_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 class GridLayoutContext: tag: Tag | None + parent: Tag | None + width: int + requested_columns: int | Literal["auto"] columns: int min_column_width: int fit_gauges: bool @@ -139,6 +147,7 @@ class GridLayoutContext: 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: @@ -161,10 +170,13 @@ class GridLayoutContext: 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.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_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: @@ -172,15 +184,35 @@ class GridLayoutContext: 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]: if not _dpg_context_active: 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: 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 config = replace(config, **updates) @@ -223,11 +255,21 @@ def create_gauge_context(gauge_type: str, config: ConfigT) -> GaugeContext[Confi 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() - if parent is not None or grid is None: - return None, None - return grid.next_cell() + if grid is None: + return None, None, None + cell, width = grid.next_cell() + return cell, width, grid def _current_grid() -> GridLayoutContext | None: @@ -239,25 +281,147 @@ def _current_grid() -> GridLayoutContext | 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) + 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 max(dpg.get_item_width(container) or 0, 0) + 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: + _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( columns: int | Literal["auto"], min_column_width: int, available_width: int ) -> int: @@ -289,7 +453,7 @@ def gauge_panel( """Create a lightweight child-window container for related gauges.""" if not _dpg_context_active: 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: parent = layout_parent if width <= 0 and layout_width is not None: @@ -326,14 +490,24 @@ def gauge_grid( ) -> GridLayoutContext: """Create a lightweight wrapping gauge layout container.""" if not _dpg_context_active: - return GridLayoutContext(None, 1, min_column_width, fit_gauges, cell_padding) - layout_parent, layout_width = _next_layout_cell(parent) + return GridLayoutContext( + 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: 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) + requested_columns = columns + resolved_columns = _resolved_columns(columns, min_column_width, available_width) container = dpg.add_table( tag=tag or 0, parent=parent or 0, @@ -345,17 +519,24 @@ def gauge_grid( scrollX=False, **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) - return GridLayoutContext( - container, - columns, - min_column_width, - fit_gauges, - cell_padding, - row_height, - available_width, + 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( diff --git a/tests/test_step3_widget_renderer.py b/tests/test_step3_widget_renderer.py index 6a72bf8..5f7c841 100644 --- a/tests/test_step3_widget_renderer.py +++ b/tests/test_step3_widget_renderer.py @@ -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" 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" diff --git a/tests/test_step7_hardening.py b/tests/test_step7_hardening.py index af68b4a..38d3ca1 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.0" + assert dpgg.__version__ == "1.1.1" 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 89cbb44..20d0d5b 100644 --- a/uv.lock +++ b/uv.lock @@ -36,7 +36,7 @@ wheels = [ [[package]] name = "dpg-gauges" -version = "1.1.0" +version = "1.1.1" source = { editable = "." } dependencies = [ { name = "dearpygui" },