diff --git a/README.md b/README.md index 5b23b57..1b546e1 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,16 @@ dpg.destroy_context() ```python with dpg.window(label="Engine"): 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.bar_gauge(tag="boost", label="Boost", max_value=30, unit="psi") 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 existing container after that container has already been created: diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index b4181a6..7a4ab89 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -24,7 +24,10 @@ Creation functions are context managers and accept common options such as `tag`, ## Layout Helpers - `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 diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index d959d5b..e3ea42d 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -15,6 +15,7 @@ Run examples with `uv run python examples/.py`. - `thresholds_markers.py` - `animations.py` - `dashboard.py` +- `layout_grid.py` - `sizing.py` - `live_high_rate.py` - `live_worst_case.py` diff --git a/examples/layout_grid.py b/examples/layout_grid.py new file mode 100644 index 0000000..43cfaba --- /dev/null +++ b/examples/layout_grid.py @@ -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() diff --git a/src/dpg_gauges/widget.py b/src/dpg_gauges/widget.py index 456544e..51ee1fc 100644 --- a/src/dpg_gauges/widget.py +++ b/src/dpg_gauges/widget.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from types import TracebackType from typing import Any, Generic, Literal, TypeVar, cast @@ -42,6 +42,8 @@ ConfigT = TypeVar("ConfigT", bound=GaugeConfig) _dpg_context_active = False _original_create_context = dpg.create_context _original_destroy_context = dpg.destroy_context +_active_grids: list[GridLayoutContext] = [] +_layout_suspend_depth = 0 def _install_context_tracking() -> None: @@ -73,10 +75,14 @@ _install_context_tracking() @dataclass class GaugeContext(Generic[ConfigT]): state: GaugeState + _suspended_layout: bool = False def __enter__(self) -> ConfigT: + global _layout_suspend_depth if _dpg_context_active and dpg.does_item_exist(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) def __exit__( @@ -85,17 +91,27 @@ class GaugeContext(Generic[ConfigT]): exc: BaseException | None, traceback: TracebackType | None, ) -> None: + global _layout_suspend_depth if _dpg_context_active and dpg.does_item_exist(self.state.container_tag): dpg.pop_container_stack() + if self._suspended_layout: + _layout_suspend_depth = max(_layout_suspend_depth - 1, 0) + self._suspended_layout = False @dataclass class LayoutContext: tag: Tag | None + suspend_layout: bool = True + _suspended_layout: bool = False 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): dpg.push_container_stack(self.tag) + if self.suspend_layout: + _layout_suspend_depth += 1 + self._suspended_layout = True return self.tag def __exit__( @@ -104,14 +120,69 @@ class LayoutContext: exc: BaseException | None, traceback: TracebackType | None, ) -> None: + global _layout_suspend_depth if self.tag is not None and _dpg_context_active and dpg.does_item_exist(self.tag): 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]: if not _dpg_context_active: 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 container_tag = tag 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) +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( *, tag: Tag | None = None, @@ -168,6 +282,11 @@ 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) + 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( tag=tag or 0, parent=parent or 0, @@ -187,27 +306,42 @@ def gauge_panel( def gauge_grid( *, columns: int = 1, + min_column_width: int = 180, + fit_gauges: bool = True, + row_height: int = 0, tag: Tag | None = None, parent: Tag | None = None, width: int = 0, height: int = 0, show: bool = True, **config: Any, -) -> LayoutContext: - """Create a lightweight horizontal gauge layout container.""" +) -> GridLayoutContext: + """Create a lightweight wrapping gauge layout container.""" if not _dpg_context_active: - return LayoutContext(None) - columns = max(int(columns), 1) - container = dpg.add_group( + return GridLayoutContext(None, 1, min_column_width, fit_gauges) + 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 + available_width = _available_layout_width(parent, width) + columns = _resolved_columns(columns, min_column_width, available_width) + container = dpg.add_table( tag=tag or 0, parent=parent or 0, width=width, height=height, show=show, - horizontal=columns > 1, + header_row=False, + policy=dpg.mvTable_SizingStretchSame, + scrollX=False, **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( diff --git a/tests/test_step3_widget_renderer.py b/tests/test_step3_widget_renderer.py index e8d8296..9a2f037 100644 --- a/tests/test_step3_widget_renderer.py +++ b/tests/test_step3_widget_renderer.py @@ -1,6 +1,7 @@ # pyright: reportGeneralTypeIssues=false from collections.abc import Generator +from typing import cast import dearpygui.dearpygui as dpg import pytest @@ -9,6 +10,10 @@ import dpg_gauges as dpgg 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 def dpg_context() -> Generator[None]: 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_grid") 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"