step 3: add widget shell sizing and frame pump

This commit is contained in:
2026-05-22 18:33:45 +02:00
parent 13b6a1e65b
commit 743a82f796
14 changed files with 568 additions and 12 deletions

View File

@@ -2,16 +2,17 @@
## Current status ## Current status
Step 2 complete. Step 3 complete.
## Completed steps ## Completed steps
Step 1 - Public API contract and pure core. Step 1 - Public API contract and pure core.
Step 2 - Thread-safe state, commands, overlays, and cache model. Step 2 - Thread-safe state, commands, overlays, and cache model.
Step 3 - Widget shell, sizing system, and GUI-thread frame pump.
## Current step ## Current step
Step 3 - Widget shell, sizing system, and GUI-thread frame pump. Step 4 - Tile manager, persistent cache, and asynchronous loading.
## Design decisions ## 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 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. - 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. - 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 pytest`.
- Ran `uv run ruff check .`. - Ran `uv run ruff check .`.
- Ran `uv run ruff format .`. - Ran `uv run ruff format .`.
@@ -48,4 +56,4 @@ None yet.
## Next action ## Next action
Implement Step 3. Implement Step 4.

View File

@@ -2,7 +2,7 @@
`dpg-map` is a Dear PyGui map widget package under rebuild. `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 ```python
import dpg_map as dpgm import dpg_map as dpgm
@@ -29,7 +29,21 @@ Implemented so far:
- command queue with coalescing for overlay and view updates - command queue with coalescing for overlay and view updates
- logical marker, polyline, trajectory, and layer models - logical marker, polyline, trajectory, and layer models
- persistent disk cache path, metadata, scanning, and prune planning - 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 Real tile loading and overlay drawing are not implemented yet. Step 4 will add
context manager used to register state; Step 3 will add the real child window, the asynchronous tile manager, persistent tile loading, and GUI-thread texture
drawlist, sizing, and GUI-thread frame pump. 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
```

27
examples/basic_map.py Normal file
View File

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

27
examples/hidden_tab.py Normal file
View File

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

28
examples/sizing_child.py Normal file
View File

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

33
examples/sizing_table.py Normal file
View File

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

27
examples/sizing_window.py Normal file
View File

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

View File

@@ -1 +1,36 @@
"""Map interaction state and handlers.""" """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))

View File

@@ -1 +1,157 @@
"""GUI-thread renderer implementation.""" """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

View File

@@ -1 +1,87 @@
"""Map sizing measurement helpers.""" """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,
)

View File

@@ -5,8 +5,10 @@ from __future__ import annotations
from collections.abc import Iterator from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Any
from .providers import TileProvider from .providers import TileProvider
from .renderer import MapRenderer
from .state import create_map_state, current_map_context from .state import create_map_state, current_map_context
from .types import LatLon, Tag from .types import LatLon, Tag
@@ -24,11 +26,7 @@ def map_widget(
autosize_y: bool = False, autosize_y: bool = False,
**kwargs: object, **kwargs: object,
) -> Iterator[Tag | None]: ) -> Iterator[Tag | None]:
"""Create a logical map context. """Create a Dear PyGui child-window map shell and 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.
"""
cache_dir_value = kwargs.get("cache_dir") cache_dir_value = kwargs.get("cache_dir")
cache_dir = cache_dir_value if isinstance(cache_dir_value, str | Path) else None 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, cache_dir=cache_dir,
user_agent=user_agent, 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): with current_map_context(state.tag):
yield state.tag yield state.tag

19
tests/test_interaction.py Normal file
View File

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

30
tests/test_renderer.py Normal file
View File

@@ -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

44
tests/test_sizing.py Normal file
View File

@@ -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