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

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

View File

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

View File

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

View File

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