step 3: add widget shell sizing and frame pump
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user