feat(ui): T-1145 round-2 polish — cover-fit, WASD+edge-scroll pan, smoothed composite (Jeroen rulings)

Cover-fit: fit_window_view zooms from the viewport's LARGER dimension,
no margin factor (any factor under 1.0 leaves a long-axis gap — checked
numerically) — the composite fills edge to edge, overhanging the short
axis into pan-space; the refloat center-equality early-return already
prevents refetch churn at rest (proved, not just tested).

Input model (Jeroen: drag breaks click semantics with map objects):
LMB-drag pan REMOVED from the regional window; clicks are
object-reserved. Pan = held WASD/arrows polled in _process (delta- and
zoom-scaled, camera-pans-toward-key convention verified numerically)
plus edge-scroll within 24px of the viewport border; both suppressed
over UI and on OS focus loss; both set _user_adjusted; wheel zoom and
Esc unchanged. Reads RAW physical keycodes deliberately — independent
of the shared D-054 move_* InputMap actions bound to the same keys
(whose occlusion-leak is pre-existing and now ticketed as T-1146).
Pole wall + east-west wrap unchanged, re-driven through the new
inputs; drag tests replaced, not kept.

Smoothed composite (interim pending T-1143): per-cell colors bake into
an n x n Image/ImageTexture (exact existing colorizer incl. overlay +
ice tint) drawn once with LINEAR filtering — GPU bilinear reads as
terrain, the planetary heightmap's own treatment. Crisp per-cell path
preserved behind COMPOSITE_SMOOTH for T-1143 A/B. Rebuild only on
reference-identity change of window/toggle (is_same — verified true
reference equality; value-equal distinct dicts DO rebuild).

Governance: T-1145 amendment paragraph on D-226 T-1124 SS5 (all three
supersessions); pql decisions validate ok.

Suites: window_viewer 74/74, window_geometry 32/32, window_overlay
(new) 16/16; gdlint clean on all six files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:51:24 +02:00
co-authored by Claude Fable 5
parent 848fa12003
commit 372ce37bb6
7 changed files with 814 additions and 147 deletions
@@ -13,29 +13,43 @@ extends RefCounted
## Fit-and-center: given the viewport size and the window's side length in
## districts, compute the zoom/offset that fills ~90% of the smaller viewport
## dimension and centers the composite. Mirrors AtlasViewer's own
## _fit_to_view() shape (fit-to-smaller-dimension, then center) but as a pure
## function returning {zoom, offset} instead of writing _view_zoom/_view_offset
## districts, compute the zoom/offset that COVERS the viewport (fills it edge
## to edge, no side margins) and centers the composite. Mirrors AtlasViewer's
## own _fit_to_view() shape (fit, then center) but as a pure function
## returning {zoom, offset} instead of writing _view_zoom/_view_offset
## directly, so AtlasWindowViewer.enter()/(_on_window_ready)/NOTIFICATION_RESIZED
## can all call the SAME formula without three copies of the math drifting.
##
## zoom = clampf(0.9 * min(viewport.x, viewport.y) / (n * cell_px), MIN_ZOOM, MAX_ZOOM)
## — the 0.9 factor leaves a visible margin around the composite (same
## "don't touch the edges" instinct as AtlasViewer's own 0.92 fit factor,
## slightly more generous here since the window composite has no header/
## overlay-bar chrome competing for the same rect the way the planetary view
## does). offset centers the (n * cell_px * zoom)-sized composite in the
## viewport.
## T-1145 item 1 (Jeroen's round-2 finding, KALLAST window): the ORIGINAL fit
## was CONTAIN (zoom from the SMALLER viewport dimension, with a 0.9 margin
## factor) — in a wide viewport this left large side margins around a square
## composite (the window data is always n x n, a square, regardless of
## viewport aspect). Changed to COVER: zoom from the LARGER viewport
## dimension, with NO margin factor — a margin on the CONTAIN axis (the one
## the zoom is computed from) is a deliberate breathing-room choice; the
## exact same margin on the COVER axis would be a literal gap at the
## viewport's own edge, which is precisely the "no side margins" defect this
## fix removes. The composite therefore fills the screen edge to edge on its
## long axis (scaled side == max(viewport.x, viewport.y) exactly) and
## overhangs past both edges on its short axis (exactly the same "cover"
## concept CSS background-size/object-fit use — fill the frame, crop what
## doesn't fit, never letterbox). This is honest for a square dataset in a
## non-square frame: at rest, the player sees a full-bleed slice of the
## window, and panning (T-1145 item 2: WASD/edge-scroll) reveals the rest,
## including triggering the existing pan-edge refetch (§4) exactly as
## intended — cover does not change what "past the window edge" means, only
## how much of the window is visible before the player pans at all.
##
## zoom = clampf(max(viewport.x, viewport.y) / (n * cell_px), MIN_ZOOM, MAX_ZOOM)
## offset centers the (n * cell_px * zoom)-sized composite on the viewport,
## exactly as the old contain fit did.
static func fit_window_view(
viewport: Vector2, n: int, cell_px: float, min_zoom: float, max_zoom: float
) -> Dictionary:
if n <= 0 or cell_px <= 0.0 or viewport.x <= 0.0 or viewport.y <= 0.0:
return {"zoom": 1.0, "offset": Vector2.ZERO}
var composite_native: float = float(n) * cell_px
var zoom: float = clampf(
0.9 * minf(viewport.x, viewport.y) / composite_native, min_zoom, max_zoom
)
var zoom: float = clampf(maxf(viewport.x, viewport.y) / composite_native, min_zoom, max_zoom)
var composite_scaled: Vector2 = Vector2(composite_native, composite_native) * zoom
var offset: Vector2 = (viewport - composite_scaled) * 0.5
return {"zoom": zoom, "offset": offset}
@@ -20,10 +20,45 @@ extends Node2D
## overlay draws nothing until the viewer has a window (border-fade during
## the wait is the VIEWER's job, drawn separately underneath this node, not
## here — this node is purely "draw the composite when there is one").
##
## T-1145 item 3 (interim presentation, pending the T-1143 design pass):
## COMPOSITE_SMOOTH := true renders the composite as an n x n Image (one
## pixel per district, EXACT same per-cell color pipeline this file always
## had — _cell_color()/_apply_glaciation() are UNCHANGED) converted to an
## ImageTexture and drawn scaled with LINEAR filtering, instead of n*n flat
## draw_rect() calls. GPU bilinear sampling between adjacent district pixels
## reads as a terrain gradient rather than hard-edged blocks — the same
## treatment the planetary heightmap already gets (Godot's engine-default
## CanvasItem.texture_filter is LINEAR_WITH_MIPMAPS project-wide, which is
## what AtlasViewer's draw_texture_rect() calls already inherit for free;
## this node sets texture_filter explicitly rather than relying on that
## default, so the choice is visible in code, not implicit). The crisp
## per-cell rect path SURVIVES behind the const (COMPOSITE_SMOOTH := false)
## so T-1143's design pass can compare both renderings directly — this is
## explicitly an INTERIM presentation, not the final answer on district-tier
## legibility (T-1143 owns that design).
##
## The texture is REBUILT only when its inputs change (the window object
## itself — a new DistrictWindowLayer arriving is a new Dictionary, checked
## by REFERENCE via is_same(), not a per-field deep compare — or the active
## toggle overlay id), not per frame/per redraw. Panning and zooming redraw
## this node constantly (every _apply_transform() call) but never touch
## window/overlay state, so the common case (panning within an already-held
## window) is zero rebuild cost — draw_texture_rect() on an already-built
## ImageTexture, same as any other texture draw.
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
## T-1145 item 3: interim presentation toggle — true renders the smoothed
## Image/ImageTexture composite; false keeps the original crisp per-cell
## draw_rect() path (both call the SAME _cell_color()/_apply_glaciation()
## pipeline, so switching this never changes WHAT color a cell reads, only
## HOW it's rendered). Left as a compile-time const, not a runtime toggle —
## T-1143's design pass is expected to pick a winner, not ship a player-
## facing switch between them.
const COMPOSITE_SMOOTH: bool = true
## Moisture ramp reuses SUB_BIOME_COLORS' dry-sand->wet-teal ENDPOINTS (§5) —
## not the categorical lookup itself (that's keyed by sub-biome NAME, not a
## 0-100 quantity). Endpoints pulled from the existing dry/wet entries in that
@@ -33,6 +68,16 @@ const COLOR_MOISTURE_WET: Color = Color(0.25, 0.72, 0.65, 1.0) # teal — match
var viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref)
## T-1145 item 3: texture rebuild cache — see the class doc's "REBUILT only
## when its inputs change" paragraph. _cache_window_ref is compared by
## REFERENCE (is_same()), not value — a fresh DistrictWindowLayer response is
## always a NEW Dictionary object (built by atlas_map_protocol.gd's decode),
## so reference identity is both correct AND far cheaper than a deep compare
## of a potentially-4096-cell dictionary on every _draw() call.
var _cached_texture: ImageTexture = null
var _cache_window_ref: Variant = null
var _cache_active_toggle: String = ""
func _draw() -> void:
if viewer == null:
@@ -46,18 +91,83 @@ func _draw() -> void:
return
var morphology: Variant = w.get("morphology")
var elev_q: Variant = w.get("elev_q")
if not (morphology is PackedByteArray or morphology is Array):
return
var cell_px: float = viewer.get_cell_pixel_size()
var active_toggle: String = _active_toggle_overlay()
if COMPOSITE_SMOOTH:
_draw_smoothed_composite(w, n, cell_px, active_toggle)
else:
_draw_crisp_composite(w, n, cell_px, active_toggle)
## T-1145 item 3: the smoothed path — build/reuse an n x n ImageTexture (one
## pixel per district) and draw it scaled to (n*cell_px) with LINEAR
## filtering. texture_filter is set on `self` (a CanvasItem property) once
## per draw — cheap (a property write, not a texture rebuild) and correct
## even the first time this runs (Godot's engine default already IS linear,
## but this makes the choice explicit rather than relying on an implicit
## project-wide default that could change).
func _draw_smoothed_composite(w: Dictionary, n: int, cell_px: float, active_toggle: String) -> void:
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
_rebuild_texture_if_needed(w, n, active_toggle)
if _cached_texture == null:
return
var extent: float = float(n) * cell_px
draw_texture_rect(_cached_texture, Rect2(0.0, 0.0, extent, extent), false)
## Rebuilds _cached_texture from `w`'s per-cell colors ONLY when the window
## object or the active toggle overlay has changed since the last build —
## see the class doc's rebuild-cost paragraph. `elev_q` and `glaciation` are
## read directly from `w` here (rather than threaded through as params, the
## way the crisp path's _cell_color()/_apply_glaciation() calls already
## receive them) since this function owns the whole per-cell loop, not just
## one cell.
func _rebuild_texture_if_needed(w: Dictionary, n: int, active_toggle: String) -> void:
if (
is_same(_cache_window_ref, w)
and _cache_active_toggle == active_toggle
and _cached_texture != null
):
return # inputs unchanged since the last build — reuse the existing texture
var elev_q: Variant = w.get("elev_q")
var glaciation: Variant = w.get("glaciation")
var morphology: Variant = w.get("morphology")
var n_cells: int = morphology.size()
var img := Image.create(n, n, false, Image.FORMAT_RGBA8)
for row in range(n):
for col in range(n):
var i: int = row * n + col
if i >= n_cells:
img.set_pixel(col, row, Color.TRANSPARENT)
continue
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
cell_color = _apply_glaciation(cell_color, glaciation, i)
img.set_pixel(col, row, cell_color)
_cached_texture = ImageTexture.create_from_image(img)
_cache_window_ref = w
_cache_active_toggle = active_toggle
## The ORIGINAL crisp per-cell path — kept byte-for-byte behind
## COMPOSITE_SMOOTH := false so T-1143's design pass can compare both
## renderings directly (see the class doc).
func _draw_crisp_composite(w: Dictionary, n: int, cell_px: float, active_toggle: String) -> void:
var elev_q: Variant = w.get("elev_q")
var glaciation: Variant = w.get("glaciation")
var morphology: Variant = w.get("morphology")
var n_cells: int = morphology.size()
for row in range(n):
for col in range(n):
var i: int = row * n + col
if i >= morphology.size():
if i >= n_cells:
continue
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
if cell_color.a <= 0.0:
@@ -21,10 +21,15 @@ extends Control
## retry — this Control decides WHEN to call it (pan-edge detection,
## entry), never talks to SimBridge directly itself.
##
## Navigation:
## Mouse drag pan within/across the window
## Mouse wheel zoom the held composite (client-side only, never refetches)
## Esc back to the planetary view
## Navigation (T-1145 item 2 — Jeroen's input-model ruling: LMB-drag panning
## BREAKS click semantics with map objects, so it is removed entirely; clicks
## are reserved for map objects, which will land in this window later, e.g.
## settlements):
## WASD / arrow keys continuous pan, held (frame-rate independent, _process)
## Edge scrolling cursor within EDGE_SCROLL_MARGIN_PX of a viewport
## edge pans toward it (suppressed over UI / unfocused)
## Mouse wheel zoom the held composite (client-side only, never refetches)
## Esc back to the planetary view
signal back_pressed
@@ -35,6 +40,24 @@ const MIN_ZOOM: float = 0.5
const MAX_ZOOM: float = 8.0
const ZOOM_STEP: float = 1.15
## T-1145 item 2: WASD/arrow-key continuous pan speed, in CANVAS px/s at
## zoom=1.0 — the ACTUAL screen-space pan rate is this value times the
## CURRENT _view_zoom (see _process()'s pan tick), so panning covers the
## same amount of TERRAIN per second regardless of zoom level. A fixed
## SCREEN-px/s rate (no zoom scaling) would feel painfully slow zoomed in
## (each screen pixel is a fraction of a district) and uncontrollably fast
## zoomed out — scaling by zoom keeps the "how much world passes per
## second" feel constant, matching the ticket's "speed in screen px/s
## scaled by zoom" wording. ~6 districts/s at zoom=1.0 (96/16) — brisk
## enough to cross a default n=32 window in ~5s, not a crawl.
const PAN_SPEED_CANVAS_PX_S: float = 96.0
## T-1145 item 2: cursor-to-edge distance (px) that triggers edge-scroll —
## Jeroen's own number ("~24px").
const EDGE_SCROLL_MARGIN_PX: float = 24.0
## Edge-scroll uses the SAME speed as WASD (one pan feel, two triggers) —
## no separate constant, _process() reads PAN_SPEED_CANVAS_PX_S for both.
## Pixel size of one district cell at zoom=1.0 — a fixed on-screen scale
## (unlike AtlasViewer's heightmap, there is no source texture dictating a
## native pixel size; this constant IS the native size). 16px/cell at n=64
@@ -97,15 +120,27 @@ var _window: Variant = null # current DistrictWindowLayer Dictionary, or null w
var _held_center: Vector2i = Vector2i.ZERO
var _held_n: int = 32
# ── Pan/zoom state (mirrors AtlasViewer's own fields exactly) ────────────
# ── Pan/zoom state ─────────────────────────────────────────────────────────
var _view_offset: Vector2 = Vector2.ZERO
var _view_zoom: float = 1.0
var _dragging: bool = false
var _drag_start_mouse: Vector2
var _drag_start_offset: Vector2
# T-1142: true once the user has manually dragged/zoomed since the last
# enter()/fit — auto-fit (enter, first window arrival, resize) only re-fits
# BEFORE this flips, so it never fights a player mid-interaction. Reset to
# T-1145 item 2: last known LOCAL mouse position (this Control's coordinate
# space), tracked from _gui_input's motion events for the edge-scroll check
# in _process() — _process() has no InputEvent of its own to read a position
# from, so the position has to be cached from the last motion event we DID
# see. Starts at -ONE (an impossible in-bounds position) so edge-scroll never
# fires before the mouse has ever moved over this Control at least once.
var _last_mouse_pos: Vector2 = Vector2(-1.0, -1.0)
# T-1145 item 2: whether the OS application window currently has focus —
# edge-scroll is suppressed while false (see _is_cursor_edge_scrolling()'s
# doc). Defaults true: a freshly-entered screen assumes focus until told
# otherwise by NOTIFICATION_APPLICATION_FOCUS_OUT (matches the game's own
# window normally having focus when the player is actively navigating the
# implant in the first place).
var _app_has_focus: bool = true
# T-1142/T-1145: true once the user has manually panned (WASD/edge-scroll,
# T-1145) or zoomed since the last enter()/fit — auto-fit (enter, first
# window arrival, resize) only re-fits BEFORE this flips, so it never fights
# a player mid-interaction. Reset to
# false on every enter() (a fresh descent always starts fitted).
var _user_adjusted: bool = false
# T-1142: true from enter() until the FIRST _on_window_ready() fires (the
@@ -354,10 +389,11 @@ func set_view(zoom: float, offset: Vector2) -> void:
# =============================================================================
## After a drag delta, check whether the screen-center now maps to a
## DistrictPos outside the held window's extent — if so, float a NEW window
## centered on that point (§5 "windows float on the pan center... not
## grid-snapped") via the debounced request path.
## After a pan delta (T-1145: WASD/edge-scroll, called from _process()'s pan
## tick every frame the player is actively panning), check whether the
## screen-center now maps to a DistrictPos outside the held window's extent
## — if so, float a NEW window centered on that point (§5 "windows float on
## the pan center... not grid-snapped") via the debounced request path.
##
## T-1142 (item 6a): the edge-crossing decision below is computed in RAW
## absolute district space (un-wrapped, un-clamped) — that is the correct
@@ -490,6 +526,17 @@ func _is_over_ui(_pos: Vector2) -> bool:
return false
## T-1145 item 2: LMB-drag panning is GONE (Jeroen's ruling — drag broke click
## semantics with map objects; clicks are reserved for future map objects,
## e.g. settlements). What remains: wheel zoom (unchanged) and tracking the
## local mouse position for edge-scroll (_process() reads _last_mouse_pos —
## it has no InputEvent of its own to read a live position from). WASD/arrow
## panning does NOT go through _gui_input at all — it is a HELD-key,
## continuous, frame-rate-independent pan polled every frame in _process()
## via Input.is_action_pressed()-equivalent raw key checks (Input.is_key_pressed(),
## since WASD has no project-level Input Map action of its own in this
## screen's remit — see _process()'s own doc for why raw physical-keycode
## polling is deliberate here, not a new InputMap action).
func _gui_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.is_echo():
_handle_key(event as InputEventKey)
@@ -509,24 +556,8 @@ func _gui_input(event: InputEvent) -> void:
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
_user_adjusted = true
_zoom_at(mb.position, 1.0 / ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
_dragging = true
_drag_start_mouse = mb.position
_drag_start_offset = _view_offset
else:
_dragging = false
elif event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
if _dragging:
_user_adjusted = true
var dragged_offset: Vector2 = _drag_start_offset + (mm.position - _drag_start_mouse)
# T-1142 pole-wall: clamp Y only (item 5) — the window edge, not
# merely its center, must never cross ±rows_half. X is untouched
# (item 6: east-west circumnavigation is seamless, no wall).
_view_offset = _clamp_offset_to_pole_wall(dragged_offset)
_apply_transform()
_maybe_refloat_window()
_last_mouse_pos = (event as InputEventMouseMotion).position
func _handle_key(event: InputEventKey) -> void:
@@ -534,6 +565,129 @@ func _handle_key(event: InputEventKey) -> void:
back_pressed.emit()
## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both applied
## here (not _gui_input) because both are HELD-state effects (keys held down,
## cursor lingering near an edge), not discrete input events — _process()
## polls held state every frame and hands the resulting direction + this
## frame's delta to _apply_pan_delta() (split out for testability — a gdUnit
## test drives _apply_pan_delta(direction, delta) directly with a
## deterministic direction/delta instead of needing to fake Godot's global
## Input singleton reporting a key held, which is what testing THIS
## function's own Input.is_key_pressed() polling would require). Skips
## entirely while this Control is hidden (the screen is not the active
## nav-stack entry) — no wasted per-frame work for an invisible viewer, and
## no phantom panning if some other code path leaves this node in the tree
## but not shown.
func _process(delta: float) -> void:
if not visible:
return
var direction: Vector2 = _held_pan_direction()
if _is_cursor_edge_scrolling():
direction += _edge_scroll_direction()
if direction == Vector2.ZERO:
return
_apply_pan_delta(direction, delta)
## The actual pan-tick state mutation, given an ALREADY-DECIDED (but not yet
## normalized) direction and this frame's delta — frame-rate independent
## (motion scales by `delta`, so the same speed at 30fps or 144fps), zoom-
## scaled (PAN_SPEED_CANVAS_PX_S * _view_zoom — see that constant's own doc
## for why), and pole-wall clamped (T-1142, unchanged mechanism, just fed by
## a different input source now). Sets _user_adjusted (T-1145: "WASD/edge/
## zoom all set _user_adjusted") and triggers the SAME pan-edge refetch check
## (§4) drag used to. Split from _process() specifically so a test can call
## this directly with a synthetic direction/delta — see _process()'s own doc.
func _apply_pan_delta(direction: Vector2, delta: float) -> void:
var normalized: Vector2 = direction.normalized() # diagonal isn't faster than a single axis
_user_adjusted = true
var delta_offset: Vector2 = -normalized * PAN_SPEED_CANVAS_PX_S * _view_zoom * delta
_view_offset = _clamp_offset_to_pole_wall(_view_offset + delta_offset)
_apply_transform()
_maybe_refloat_window()
## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode
## (not an InputMap action): W/S/A/D on this project's global InputMap are
## already bound to move_north/move_south/move_east/move_west (gameplay
## movement, D-054 mouse-relative facing) — reusing those actions here would
## make holding W simultaneously pan this map AND queue a gameplay move
## command server-side the moment this implant screen closes back to
## gameplay (InputMapper polls Input.is_action_pressed() unconditionally,
## with no implant-occlusion guard — confirmed by reading input_mapper.gd
## directly, a genuine pre-existing gap outside this ticket's scope, not
## introduced here). Reading the raw physical keycode instead of the shared
## action name means this screen's WASD use is fully independent of
## whatever the gameplay action happens to be bound to — same key, two
## UNRELATED consumers, neither needs to know about the other. Arrow keys
## have no InputMap action bound at all (confirmed by grep across
## project.godot's [input] section), so they're conflict-free either way.
## Returns a raw (non-normalized) direction — the caller normalizes once
## after adding the edge-scroll contribution, so N+E doesn't move faster
## than N alone.
func _held_pan_direction() -> Vector2:
var direction := Vector2.ZERO
if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP):
direction.y -= 1.0
if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN):
direction.y += 1.0
if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT):
direction.x -= 1.0
if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT):
direction.x += 1.0
return direction
## T-1145 item 2: edge-scroll is suppressed (a) while the cursor is over UI
## (_is_over_ui() — the SAME helper the click-era _gui_input guard used, per
## the ticket's explicit "reuse _is_over_ui" instruction) and (b) while the
## application window itself lacks OS focus (_app_has_focus — otherwise a
## background window with the cursor left resting near its edge from a
## previous session would silently pan while the player is doing something
## else entirely; "if detectable" per the ticket, and Godot's
## NOTIFICATION_APPLICATION_FOCUS_OUT/IN make it directly detectable, see
## _notification()).
func _is_cursor_edge_scrolling() -> bool:
if not _app_has_focus:
return false
if _is_over_ui(_last_mouse_pos):
return false
var sz: Vector2 = size
if sz.x <= 0.0 or sz.y <= 0.0:
return false
var pos: Vector2 = _last_mouse_pos
return (
pos.x >= 0.0
and pos.y >= 0.0
and pos.x <= sz.x
and pos.y <= sz.y
and (
pos.x < EDGE_SCROLL_MARGIN_PX
or pos.y < EDGE_SCROLL_MARGIN_PX
or pos.x > sz.x - EDGE_SCROLL_MARGIN_PX
or pos.y > sz.y - EDGE_SCROLL_MARGIN_PX
)
)
## Direction toward whichever edge(s) the cursor is near — same shape as
## _held_pan_direction() (a raw, un-normalized Vector2 the caller combines
## and normalizes once).
func _edge_scroll_direction() -> Vector2:
var sz: Vector2 = size
var pos: Vector2 = _last_mouse_pos
var direction := Vector2.ZERO
if pos.x < EDGE_SCROLL_MARGIN_PX:
direction.x -= 1.0
elif pos.x > sz.x - EDGE_SCROLL_MARGIN_PX:
direction.x += 1.0
if pos.y < EDGE_SCROLL_MARGIN_PX:
direction.y -= 1.0
elif pos.y > sz.y - EDGE_SCROLL_MARGIN_PX:
direction.y += 1.0
return direction
# =============================================================================
# Overlay bar / legend (reuses atlas_overlay_bar.gd/atlas_legend_panel.gd —
# both call only get_overlay_defs()/is_overlay_visible()/set_overlay_visible(),
@@ -581,6 +735,10 @@ func _notification(what: int) -> void:
# is constructed — confirmed the hard way (gdUnit add_child() crash).
if _canvas and not _user_adjusted:
_fit_and_center()
elif what == NOTIFICATION_APPLICATION_FOCUS_OUT:
_app_has_focus = false
elif what == NOTIFICATION_APPLICATION_FOCUS_IN:
_app_has_focus = true
## Safely extract a string field from a dict, falling back when missing or