diff --git a/AGENTS.md b/AGENTS.md index e00503d..6826117 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,16 +2,17 @@ ## Current status -Step 2 complete. +Step 3 complete. ## Completed steps Step 1 - Public API contract and pure core. Step 2 - Thread-safe state, commands, overlays, and cache model. +Step 3 - Widget shell, sizing system, and GUI-thread frame pump. ## Current step -Step 3 - Widget shell, sizing system, and GUI-thread frame pump. +Step 4 - Tile manager, persistent cache, and asynchronous loading. ## Design decisions @@ -40,6 +41,13 @@ None yet. - Implemented public runtime overlay/view/layer/provider/cache/debug wrappers against logical state without Dear PyGui calls. - Implemented memory cache metadata, disk cache path generation, metadata read/write, disk size scanning, and prune planning. - Added Step 2 tests for command coalescing, overlay/view isolation, copied trajectory inputs, coordinate length validation, layer state, disk path generation, and prune ordering. +- Implemented `map_widget(...)` as a Dear PyGui child-window plus drawlist shell. +- Implemented GUI-thread renderer frame pump that schedules frame callbacks, drains command queues, measures size, resizes the drawlist, and draws a placeholder background/attribution. +- Implemented sizing helpers for measured size, last non-zero size preservation, visibility transitions, effective draw size, and resize dirty flags. +- Implemented map interaction hit-rectangle calculation. +- Added Step 3 examples for basic map, window sizing, child-window sizing, table sizing, and hidden-tab sizing. +- Added Step 3 tests for sizing transitions, zero-size preservation, resize dirty flags, command drain ordering, and hit rectangles. +- Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist creation. - Ran `uv run pytest`. - Ran `uv run ruff check .`. - Ran `uv run ruff format .`. @@ -48,4 +56,4 @@ None yet. ## Next action -Implement Step 3. +Implement Step 4. diff --git a/README.md b/README.md index 8ceb49e..3e444a6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `dpg-map` is a Dear PyGui map widget package under rebuild. -The Step 2 logical runtime model is in place: +The Step 3 widget shell is in place: ```python import dpg_map as dpgm @@ -29,7 +29,21 @@ Implemented so far: - command queue with coalescing for overlay and view updates - logical marker, polyline, trajectory, and layer models - persistent disk cache path, metadata, scanning, and prune planning +- Dear PyGui `child_window` + measured-size `drawlist` widget shell +- GUI-thread frame pump that drains commands and redraws a placeholder background +- sizing helpers that preserve the last non-zero size across hidden layouts +- interaction hit-rectangle calculation for the measured map area -Dear PyGui rendering is not implemented yet. The current `map_widget` is a logical -context manager used to register state; Step 3 will add the real child window, -drawlist, sizing, and GUI-thread frame pump. +Real tile loading and overlay drawing are not implemented yet. Step 4 will add +the asynchronous tile manager, persistent tile loading, and GUI-thread texture +creation. + +Examples: + +```bash +uv run python examples/basic_map.py +uv run python examples/sizing_window.py +uv run python examples/sizing_child.py +uv run python examples/sizing_table.py +uv run python examples/hidden_tab.py +``` diff --git a/examples/basic_map.py b/examples/basic_map.py new file mode 100644 index 0000000..9f76d5c --- /dev/null +++ b/examples/basic_map.py @@ -0,0 +1,27 @@ +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpg.create_context() + dpg.create_viewport(title="dpg-map basic", width=900, height=600) + + with ( + dpg.window(label="Map", width=-1, height=-1), + dpgm.map_widget(tag="map", center=(47.9029, 1.9093), zoom=15, width=-1, height=-1), + ): + dpgm.add_marker("vehicle", lat=47.9029, lon=1.9093) + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/hidden_tab.py b/examples/hidden_tab.py new file mode 100644 index 0000000..0465235 --- /dev/null +++ b/examples/hidden_tab.py @@ -0,0 +1,27 @@ +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpg.create_context() + dpg.create_viewport(title="dpg-map hidden tab", width=900, height=600) + + with dpg.window(label="Hidden Tab Sizing", width=-1, height=-1), dpg.tab_bar(): + with dpg.tab(label="First"): + dpg.add_text("Switch to the map tab.") + with dpg.tab(label="Map"), dpgm.map_widget(tag="map-hidden-tab", width=-1, height=500): + dpgm.add_marker("tab-marker", lat=35.0, lon=139.0) + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/sizing_child.py b/examples/sizing_child.py new file mode 100644 index 0000000..9f11d68 --- /dev/null +++ b/examples/sizing_child.py @@ -0,0 +1,28 @@ +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpg.create_context() + dpg.create_viewport(title="dpg-map child sizing", width=900, height=600) + + with ( + dpg.window(label="Nested Child", width=-1, height=-1), + dpg.child_window(width=-1, height=420), + dpgm.map_widget(tag="map-child", width=-1, height=-1), + ): + dpgm.add_marker("inside-child", lat=47.0, lon=2.0) + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/sizing_table.py b/examples/sizing_table.py new file mode 100644 index 0000000..9889310 --- /dev/null +++ b/examples/sizing_table.py @@ -0,0 +1,33 @@ +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpg.create_context() + dpg.create_viewport(title="dpg-map table sizing", width=1000, height=600) + + with ( + dpg.window(label="Table Layout", width=-1, height=-1), + dpg.table(header_row=True, resizable=True, policy=dpg.mvTable_SizingStretchProp), + ): + dpg.add_table_column(label="Map") + dpg.add_table_column(label="Controls") + with dpg.table_row(): + with dpg.table_cell(), dpgm.map_widget(tag="map-table", width=-1, height=500): + dpgm.add_marker("table-marker", lat=51.5, lon=-0.1) + with dpg.table_cell(): + dpg.add_text("Resize the window and table columns.") + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/sizing_window.py b/examples/sizing_window.py new file mode 100644 index 0000000..c052c72 --- /dev/null +++ b/examples/sizing_window.py @@ -0,0 +1,27 @@ +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpg.create_context() + dpg.create_viewport(title="dpg-map window sizing", width=900, height=600) + + with ( + dpg.window(label="Fill Window", width=-1, height=-1), + dpgm.map_widget(tag="map-window", width=-1, height=-1), + ): + dpgm.add_marker("center", lat=0.0, lon=0.0) + + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/src/dpg_map/interaction.py b/src/dpg_map/interaction.py index f1b9a0e..59197b2 100644 --- a/src/dpg_map/interaction.py +++ b/src/dpg_map/interaction.py @@ -1 +1,36 @@ """Map interaction state and handlers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .sizing import effective_draw_size +from .state import MapState + + +@dataclass(frozen=True, slots=True) +class HitRect: + """Screen-space rectangle used for map interaction tests.""" + + x: float + y: float + width: float + height: float + + @property + def right(self) -> float: + return self.x + self.width + + @property + def bottom(self) -> float: + return self.y + self.height + + def contains(self, x: float, y: float) -> bool: + return self.x <= x <= self.right and self.y <= y <= self.bottom + + +def calculate_hit_rect(state: MapState, drawlist_pos: tuple[float, float]) -> HitRect: + """Return the map interaction rectangle for a drawlist position.""" + + width, height = effective_draw_size(state) + return HitRect(float(drawlist_pos[0]), float(drawlist_pos[1]), float(width), float(height)) diff --git a/src/dpg_map/renderer.py b/src/dpg_map/renderer.py index 39f103d..e8f5c2c 100644 --- a/src/dpg_map/renderer.py +++ b/src/dpg_map/renderer.py @@ -1 +1,157 @@ """GUI-thread renderer implementation.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from .commands import CommandKind, MapCommand +from .interaction import HitRect, calculate_hit_rect +from .sizing import SizeMeasurement, apply_size_measurement +from .state import DirtyFlags, MapState + + +class MapRenderer: + """GUI-thread renderer for the Step 3 widget shell.""" + + def __init__(self, state: MapState, dpg: Any) -> None: + self.state = state + self._dpg = dpg + self._background_tag = f"{state.tag}##background" + self._title_tag = f"{state.tag}##placeholder-title" + self._attribution_tag = f"{state.tag}##attribution" + self.last_drained_commands: tuple[MapCommand, ...] = () + self.last_hit_rect: HitRect | None = None + + def schedule_next_frame(self) -> None: + """Schedule this renderer to run on the next Dear PyGui frame.""" + + with self.state.lock: + if self.state.frame_scheduled: + return + self.state.frame_scheduled = True + frame = self._dpg.get_frame_count() + 1 + self._dpg.set_frame_callback(frame, self._frame_callback) + + def _frame_callback(self, sender: Any | None = None, app_data: Any | None = None) -> None: + _ = (sender, app_data) + with self.state.lock: + self.state.frame_scheduled = False + if not self._dpg.does_item_exist(self.state.drawlist_tag): + return + self.render_frame() + self.schedule_next_frame() + + def render_frame(self) -> None: + """Drain pending commands, refresh size, and draw the placeholder shell.""" + + commands = drain_renderer_commands(self.state) + self.last_drained_commands = tuple(commands) + self._update_size_from_dpg() + + with self.state.lock: + dirty = self.state.dirty + should_draw = bool(dirty & (DirtyFlags.FULL | DirtyFlags.SIZE)) + visible = self.state.is_visible + width = self.state.measured_width or self.state.last_nonzero_width + height = self.state.measured_height or self.state.last_nonzero_height + provider_attribution = self.state.provider.attribution + self.state.dirty = DirtyFlags.NONE + + if should_draw and visible: + self._draw_placeholder(width, height, provider_attribution) + + def _update_size_from_dpg(self) -> None: + width, height = self._measure_child_content() + visible = bool(self._dpg.is_item_shown(self.state.child_window_tag)) + with self.state.lock: + update = apply_size_measurement( + self.state, + SizeMeasurement(width=width, height=height, visible=visible), + ) + draw_width = update.effective_width + draw_height = update.effective_height + self._dpg.configure_item(self.state.drawlist_tag, width=draw_width, height=draw_height) + draw_pos = tuple(float(value) for value in self._dpg.get_item_pos(self.state.drawlist_tag)) + with self.state.lock: + self.last_hit_rect = calculate_hit_rect(self.state, (draw_pos[0], draw_pos[1])) + + def _measure_child_content(self) -> tuple[int, int]: + try: + width, height = self._dpg.get_item_rect_size(self.state.child_window_tag) + except Exception: + return (0, 0) + return (max(0, int(width)), max(0, int(height))) + + def _draw_placeholder(self, width: int, height: int, attribution: str) -> None: + width = max(1, int(width)) + height = max(1, int(height)) + self._dpg.delete_item(self.state.drawlist_tag, children_only=True) + self._dpg.draw_rectangle( + (0, 0), + (width, height), + parent=self.state.drawlist_tag, + tag=self._background_tag, + color=(54, 68, 78, 255), + fill=(29, 38, 45, 255), + ) + self._dpg.draw_text( + (12, 12), + "dpg-map", + parent=self.state.drawlist_tag, + tag=self._title_tag, + color=(232, 238, 242, 255), + size=18, + ) + label = attribution or "Map tiles load in Step 4" + text_y = max(28, height - 24) + self._dpg.draw_text( + (12, text_y), + label, + parent=self.state.drawlist_tag, + tag=self._attribution_tag, + color=(172, 184, 192, 255), + size=12, + ) + + +def drain_renderer_commands(state: MapState) -> list[MapCommand]: + """Drain and apply GUI-thread command side effects that exist in Step 3.""" + + commands = state.command_queue.drain() + if not commands: + return [] + + with state.lock: + for command in commands: + if command.kind is CommandKind.SET_VIEW: + state.dirty |= DirtyFlags.VIEW | DirtyFlags.TILES | DirtyFlags.OVERLAYS + elif command.kind is CommandKind.SET_PROVIDER: + state.dirty |= DirtyFlags.PROVIDER | DirtyFlags.TILES + elif command.kind in { + CommandKind.ADD_OVERLAY, + CommandKind.UPDATE_OVERLAY, + CommandKind.DELETE_OVERLAY, + CommandKind.SET_LAYER_VISIBILITY, + CommandKind.ADD_LAYER, + CommandKind.CLEAR_LAYER, + }: + state.dirty |= DirtyFlags.OVERLAYS + elif command.kind is CommandKind.CLEAR_MAP: + state.dirty |= DirtyFlags.TILES | DirtyFlags.OVERLAYS + elif command.kind in { + CommandKind.CLEAR_MEMORY_CACHE, + CommandKind.CLEAR_DISK_CACHE, + }: + state.dirty |= DirtyFlags.TILES + return commands + + +def make_frame_pump(state: MapState, dpg: Any) -> Callable[[], None]: + """Create and attach a renderer frame pump for a map state.""" + + renderer = MapRenderer(state, dpg) + with state.lock: + state.renderer = renderer + renderer.schedule_next_frame() + return renderer.render_frame diff --git a/src/dpg_map/sizing.py b/src/dpg_map/sizing.py index 01de1a5..3342b0c 100644 --- a/src/dpg_map/sizing.py +++ b/src/dpg_map/sizing.py @@ -1 +1,87 @@ """Map sizing measurement helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .state import DirtyFlags, MapState, mark_dirty + + +@dataclass(frozen=True, slots=True) +class SizeMeasurement: + """Concrete size and visibility reported by the GUI thread.""" + + width: int + height: int + visible: bool + + +@dataclass(frozen=True, slots=True) +class SizeUpdate: + """Result of applying one GUI size measurement.""" + + changed: bool + became_visible: bool + became_hidden: bool + effective_width: int + effective_height: int + + +def normalize_dimension(value: int | float | None) -> int: + """Convert Dear PyGui size values to stable integer pixels.""" + + if value is None: + return 0 + return max(0, int(value)) + + +def effective_draw_size(state: MapState) -> tuple[int, int]: + """Return the size the drawlist should use for the current frame.""" + + width = state.measured_width or state.last_nonzero_width or 1 + height = state.measured_height or state.last_nonzero_height or 1 + return (max(1, width), max(1, height)) + + +def apply_size_measurement( + state: MapState, + measurement: SizeMeasurement, + *, + mark: bool = True, +) -> SizeUpdate: + """Apply a GUI-thread size measurement to logical state. + + A zero measurement is preserved as the current measured size, but it does not + erase the last non-zero size. This keeps hidden maps from permanently + collapsing when Dear PyGui reports a temporary zero content region. + """ + + width = normalize_dimension(measurement.width) + height = normalize_dimension(measurement.height) + visible = bool(measurement.visible and width > 0 and height > 0) + + previous_width = state.measured_width + previous_height = state.measured_height + previous_visible = state.is_visible + + changed = previous_width != width or previous_height != height or previous_visible != visible + + state.measured_width = width + state.measured_height = height + state.is_visible = visible + if width > 0: + state.last_nonzero_width = width + if height > 0: + state.last_nonzero_height = height + + if changed and mark: + mark_dirty(state, DirtyFlags.SIZE | DirtyFlags.TILES | DirtyFlags.OVERLAYS) + + effective_width, effective_height = effective_draw_size(state) + return SizeUpdate( + changed=changed, + became_visible=visible and not previous_visible, + became_hidden=previous_visible and not visible, + effective_width=effective_width, + effective_height=effective_height, + ) diff --git a/src/dpg_map/widget.py b/src/dpg_map/widget.py index e829fb1..60acc92 100644 --- a/src/dpg_map/widget.py +++ b/src/dpg_map/widget.py @@ -5,8 +5,10 @@ from __future__ import annotations from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path +from typing import Any from .providers import TileProvider +from .renderer import MapRenderer from .state import create_map_state, current_map_context from .types import LatLon, Tag @@ -24,11 +26,7 @@ def map_widget( autosize_y: bool = False, **kwargs: object, ) -> Iterator[Tag | None]: - """Create a logical map context. - - Dear PyGui item creation is added in a later rebuild step; this context currently - registers thread-safe logical state so overlays can be declared in context. - """ + """Create a Dear PyGui child-window map shell and logical map context.""" cache_dir_value = kwargs.get("cache_dir") cache_dir = cache_dir_value if isinstance(cache_dir_value, str | Path) else None @@ -46,5 +44,29 @@ def map_widget( cache_dir=cache_dir, user_agent=user_agent, ) + import dearpygui.dearpygui as dpg + + child_kwargs: dict[str, Any] = dict(kwargs) + child_kwargs.pop("cache_dir", None) + child_kwargs.pop("user_agent", None) + child_kwargs.setdefault("border", False) + child_kwargs.setdefault("no_scrollbar", True) + child_kwargs.setdefault("no_scroll_with_mouse", True) + + dpg.add_child_window( + tag=state.child_window_tag, + width=width, + height=height, + autosize_x=autosize_x, + autosize_y=autosize_y, + **child_kwargs, + ) + dpg.add_drawlist(1, 1, tag=state.drawlist_tag, parent=state.child_window_tag) + + renderer = MapRenderer(state, dpg) + with state.lock: + state.renderer = renderer + renderer.schedule_next_frame() + with current_map_context(state.tag): yield state.tag diff --git a/tests/test_interaction.py b/tests/test_interaction.py new file mode 100644 index 0000000..465b01e --- /dev/null +++ b/tests/test_interaction.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dpg_map.interaction import calculate_hit_rect +from dpg_map.sizing import SizeMeasurement, apply_size_measurement +from dpg_map.state import create_map_state + + +def test_hit_rect_uses_effective_map_size() -> None: + state = create_map_state(tag="hit-rect") + apply_size_measurement(state, SizeMeasurement(width=400, height=250, visible=True)) + + rect = calculate_hit_rect(state, (10.0, 20.0)) + + assert rect.x == 10.0 + assert rect.y == 20.0 + assert rect.width == 400 + assert rect.height == 250 + assert rect.contains(410.0, 270.0) + assert not rect.contains(411.0, 270.0) diff --git a/tests/test_renderer.py b/tests/test_renderer.py new file mode 100644 index 0000000..4a9aab2 --- /dev/null +++ b/tests/test_renderer.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dpg_map.commands import CommandKind, MapCommand +from dpg_map.renderer import drain_renderer_commands +from dpg_map.state import DirtyFlags, create_map_state + + +def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> None: + state = create_map_state(tag="renderer-drain") + state.dirty = DirtyFlags.NONE + + state.command_queue.put(MapCommand(CommandKind.ADD_OVERLAY, state.tag, {"tag": "a"})) + state.command_queue.put(MapCommand(CommandKind.SET_VIEW, state.tag, {"zoom": 3})) + state.command_queue.put(MapCommand(CommandKind.SET_VIEW, state.tag, {"zoom": 4})) + state.command_queue.put(MapCommand(CommandKind.UPDATE_OVERLAY, state.tag, {"tag": "a", "v": 1})) + state.command_queue.put(MapCommand(CommandKind.UPDATE_OVERLAY, state.tag, {"tag": "a", "v": 2})) + state.command_queue.put(MapCommand(CommandKind.DELETE_OVERLAY, state.tag, {"tag": "a"})) + + commands = drain_renderer_commands(state) + + assert [command.kind for command in commands] == [ + CommandKind.ADD_OVERLAY, + CommandKind.SET_VIEW, + CommandKind.UPDATE_OVERLAY, + CommandKind.DELETE_OVERLAY, + ] + assert commands[1].payload == {"zoom": 4} + assert commands[2].payload == {"tag": "a", "v": 2} + assert state.dirty & DirtyFlags.VIEW + assert state.dirty & DirtyFlags.OVERLAYS diff --git a/tests/test_sizing.py b/tests/test_sizing.py new file mode 100644 index 0000000..4124b40 --- /dev/null +++ b/tests/test_sizing.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from dpg_map.sizing import SizeMeasurement, apply_size_measurement, effective_draw_size +from dpg_map.state import DirtyFlags, create_map_state + + +def test_size_measurement_tracks_last_nonzero_size() -> None: + state = create_map_state(tag="size-last-nonzero") + + first = apply_size_measurement(state, SizeMeasurement(width=640, height=360, visible=True)) + hidden = apply_size_measurement(state, SizeMeasurement(width=0, height=0, visible=False)) + + assert first.changed is True + assert state.measured_width == 0 + assert state.measured_height == 0 + assert state.last_nonzero_width == 640 + assert state.last_nonzero_height == 360 + assert hidden.became_hidden is True + assert effective_draw_size(state) == (640, 360) + + +def test_zero_size_does_not_permanently_collapse_map() -> None: + state = create_map_state(tag="size-reappears") + + apply_size_measurement(state, SizeMeasurement(width=500, height=300, visible=True)) + apply_size_measurement(state, SizeMeasurement(width=0, height=0, visible=False)) + update = apply_size_measurement(state, SizeMeasurement(width=700, height=450, visible=True)) + + assert update.became_visible is True + assert update.effective_width == 700 + assert update.effective_height == 450 + assert state.last_nonzero_width == 700 + assert state.last_nonzero_height == 450 + + +def test_resize_marks_size_dirty() -> None: + state = create_map_state(tag="size-dirty") + state.dirty = DirtyFlags.NONE + + apply_size_measurement(state, SizeMeasurement(width=320, height=240, visible=True)) + + assert state.dirty & DirtyFlags.SIZE + assert state.dirty & DirtyFlags.TILES + assert state.dirty & DirtyFlags.OVERLAYS