diff --git a/client/tests/test_step_canvas_viewer.gd b/client/tests/test_step_canvas_viewer.gd index afa0bac1c..886c0c60f 100644 --- a/client/tests/test_step_canvas_viewer.gd +++ b/client/tests/test_step_canvas_viewer.gd @@ -261,7 +261,7 @@ func test_maybe_refloat_does_not_refloat_on_a_small_pan() -> void: ## A large pan (past half the canvas footprint) DOES re-float — new ## world_center, view_offset reset to ZERO (the canvas re-centers under the ## new request). -func test_maybe_refloat_refloats_once_the_pan_crosses_the_edge_threshold() -> void: +func test_maybe_refloat_refloats_once_the_pan_crosses_the_hard_threshold() -> void: var v: StepCanvasViewer = _make_viewer() add_child(v) v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) @@ -281,6 +281,68 @@ func test_maybe_refloat_refloats_once_the_pan_crosses_the_edge_threshold() -> vo ).is_equal(Vector2.ZERO) +## Pair session 2026-07-26 — the SOFT threshold: a pan that crosses it must +## NOT re-float on the spot. It schedules the shared settle timer and leaves +## the view alone, so a held edge-scroll keeps panning the current canvas +## smoothly and issues ONE request when it stops, instead of a request plus a +## view snap on every frame past the threshold (the old behavior). +func test_soft_pan_drift_schedules_the_settle_instead_of_refloating_now() -> void: + var v: StepCanvasViewer = _make_viewer() + add_child(v) + v.size = Vector2(800.0, 600.0) + v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + v._scroll_rung(1, Vector2(400.0, 300.0)) # District + v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(512, 384), v.get_held_rung(), "") + v._view_offset = v._centered_view_offset() + + # Drift to just past SOFT but well short of HARD. + var half: Vector2 = v._terrain_layer.get_footprint_px() * 0.5 + var target: float = ( + StepCanvasViewer.PAN_REFLOAT_SOFT_FRACTION + StepCanvasViewer.PAN_REFLOAT_HARD_FRACTION + ) * 0.5 + var screen_center: Vector2 = v.get_rect().size * 0.5 + v._view_offset = screen_center - half + Vector2(half.x * target, 0.0) + + var fraction: float = v._pan_drift_fraction() + assert_float(fraction).override_failure_message( + "test setup: drift must land between the soft and hard thresholds" + ).is_between(StepCanvasViewer.PAN_REFLOAT_SOFT_FRACTION, StepCanvasViewer.PAN_REFLOAT_HARD_FRACTION) + + var center_before: Vector2 = v._world_center + var offset_before: Vector2 = v._view_offset + v._maybe_refloat() + + assert_that(v._world_center).override_failure_message( + "a soft-threshold pan must NOT re-float immediately" + ).is_equal(center_before) + assert_that(v._view_offset).override_failure_message( + "a soft-threshold pan must not snap the view — the held canvas keeps panning" + ).is_equal(offset_before) + assert_bool(v._refetch_settle_timer.is_stopped()).override_failure_message( + "a soft-threshold pan must schedule the shared settle timer" + ).is_false() + + +## The inverse: panning back inside the soft threshold cancels the pending +## settle — the view no longer wants a different canvas, so the request that +## was about to go out must not. +func test_panning_back_inside_the_soft_threshold_cancels_the_pending_settle() -> void: + var v: StepCanvasViewer = _make_viewer() + add_child(v) + v.size = Vector2(800.0, 600.0) + v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + v._scroll_rung(1, Vector2(400.0, 300.0)) + v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(512, 384), v.get_held_rung(), "") + + v._refetch_settle_timer.start() # pretend a soft crossing already scheduled one + v._view_offset = v._centered_view_offset() # centred == zero drift + v._maybe_refloat() + + assert_bool(v._refetch_settle_timer.is_stopped()).override_failure_message( + "drifting back inside the soft threshold must cancel the pending refetch" + ).is_true() + + ## Global never re-floats on pan (D-255(a): its canvas is the whole body, ## no edge to cross) — even with a real texture held and a huge drift. func test_maybe_refloat_is_a_no_op_at_global_rung() -> void: diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_fetch_overlay.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_fetch_overlay.gd new file mode 100644 index 000000000..95f9630e4 --- /dev/null +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_fetch_overlay.gd @@ -0,0 +1,112 @@ +extends Control + +## The Atlas's diegetic fetch readout — "DOWNLOADING MAP DATA" over the held +## map while a step canvas is in flight (pair session 2026-07-26). +## +## WHY THIS IS ITS OWN NODE, not a `_draw()` in StepCanvasViewer: a Control +## paints its own `_draw()` BEFORE its children, so anything the viewer drew +## directly landed UNDERNEATH `_canvas` (the terrain texture) and was only +## ever visible when no texture existed at all. That is exactly why the +## previous treatment (a "DERIVING TERRAIN" label + a 12%-alpha wash, both +## drawn by the viewer itself) was invisible during a real slow fetch — the +## case it existed for. This node is added AFTER `_canvas` in the viewer's +## `_ready()`, so it draws on top of the map by construction. +## +## Indeterminate, not a real percentage: the server reports no derive +## sub-steps today, so a progress fraction would be invented. A sweeping +## segment is the honest signal — "working, no ETA". A true progress bar is a +## wire addition (StepCanvasResponse progress frames), deliberately deferred. +## +## The panel appears only after a grace delay so cache hits never flash it, +## and it never blanks the screen: hold-and-swap keeps the previous rung's +## canvas visible underneath (D-255's "no blank frames" rule). + +const LABEL_TEXT: String = "DOWNLOADING MAP DATA" +const GRACE_S: float = 0.25 +const PANEL_SIZE: Vector2 = Vector2(360.0, 92.0) +const BAR_HEIGHT: float = 10.0 +const BAR_INSET: float = 24.0 +const SWEEP_FRACTION: float = 0.32 +const SWEEP_PERIOD_S: float = 1.1 + +const COLOR_PANEL_BG: Color = Color(0.05, 0.07, 0.09, 0.88) +const COLOR_PANEL_BORDER: Color = Color(0.36, 0.62, 0.73, 0.85) +const COLOR_LABEL: Color = Color("#c8d0e0") +const COLOR_BAR_TRACK: Color = Color(0.16, 0.20, 0.24, 0.9) +const COLOR_BAR_LIT: Color = Color("#4a9ebb") + +## Seconds the current fetch has been pending, or -1.0 when idle. One clock +## drives both the grace gate and the sweep phase, so the animation always +## starts at the panel's first visible frame rather than mid-cycle. +var _elapsed_s: float = -1.0 + + +func _ready() -> void: + anchor_right = 1.0 + anchor_bottom = 1.0 + grow_horizontal = Control.GROW_DIRECTION_BOTH + grow_vertical = Control.GROW_DIRECTION_BOTH + # Never intercept input: the viewer beneath owns pan/zoom/click entirely. + mouse_filter = Control.MOUSE_FILTER_IGNORE + + +## Driven from the viewer's `_process()` (the viewer owns the request and +## therefore the pending state; this node just renders it). Repaints per frame +## only while the panel is up — the sweep is an animation and the viewer's +## ordinary event-driven redraws (canvas arrival, pan, resize) do not cover it. +func tick(delta: float, is_pending: bool) -> void: + if not is_pending: + var was_showing := is_showing() + _elapsed_s = -1.0 + if was_showing: + queue_redraw() # clear the panel the frame the fetch lands + return + if _elapsed_s < 0.0: + _elapsed_s = 0.0 + _elapsed_s += delta + if is_showing(): + queue_redraw() + + +func is_showing() -> bool: + return _elapsed_s >= GRACE_S + + +func _draw() -> void: + if not is_showing(): + return + var origin: Vector2 = ((size - PANEL_SIZE) * 0.5).floor() + var panel := Rect2(origin, PANEL_SIZE) + draw_rect(panel, COLOR_PANEL_BG) + draw_rect(panel, COLOR_PANEL_BORDER, false, 1.0) + + var font := ThemeDB.fallback_font + var font_size := 16 + var text_size: Vector2 = font.get_string_size( + LABEL_TEXT, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size + ) + var text_pos := Vector2(origin.x + (PANEL_SIZE.x - text_size.x) * 0.5, origin.y + 34.0) + draw_string(font, text_pos, LABEL_TEXT, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, COLOR_LABEL) + + var track := Rect2( + origin + Vector2(BAR_INSET, PANEL_SIZE.y - BAR_INSET - BAR_HEIGHT), + Vector2(PANEL_SIZE.x - BAR_INSET * 2.0, BAR_HEIGHT) + ) + draw_rect(track, COLOR_BAR_TRACK) + draw_rect(sweep_rect(track, _elapsed_s), COLOR_BAR_LIT) + + +## Pure geometry for the indeterminate sweep — static so it is unit-testable +## without a live fetch. The lit segment is SWEEP_FRACTION of the track, +## travelling left to right once per SWEEP_PERIOD_S and wrapping, clipped to +## the track at both ends so it never paints outside the panel. +static func sweep_rect(track: Rect2, elapsed_s: float) -> Rect2: + var seg_w: float = track.size.x * SWEEP_FRACTION + var travel: float = track.size.x + seg_w + var phase: float = fposmod(elapsed_s, SWEEP_PERIOD_S) / SWEEP_PERIOD_S + var lead: float = track.position.x - seg_w + travel * phase + var lo: float = maxf(lead, track.position.x) + var hi: float = minf(lead + seg_w, track.position.x + track.size.x) + if hi <= lo: + return Rect2(track.position, Vector2(0.0, track.size.y)) + return Rect2(Vector2(lo, track.position.y), Vector2(hi - lo, track.size.y)) diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd index 53afdd96e..f238673b2 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd @@ -67,7 +67,37 @@ const EDGE_SCROLL_MARGIN_PX: float = 24.0 const COLOR_BG: Color = Color("#0d1117") const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55) const COLOR_PENDING_WASH: Color = Color(0.20, 0.24, 0.30, 0.12) -const COLOR_DERIVING_LABEL: Color = Color("#667788") + +## The diegetic fetch readout lives in its own overlay node (added AFTER +## `_canvas` so it paints above the map — see StepCanvasFetchOverlay's own +## header for why a viewer-owned `_draw()` could never work). +const StepCanvasFetchOverlay := preload( + "res://ui/implant/apps/atlas/step_canvas/step_canvas_fetch_overlay.gd" +) + +## ONE settle delay for every "the view wants a different canvas than the one +## we hold" trigger — window/implant resize AND pan-edge drift (pair session +## 2026-07-26; Jeroen: "the debounce for the edge feels similar to the +## mechanism we would need for edge scrolling. Roll it into the current +## effort?"). Both are the same event: the user is still moving, so wait for +## them to stop and issue ONE request for where they actually landed. +## Long enough to collapse a drag, short enough to feel responsive. +const REFETCH_SETTLE_S: float = 0.30 + +## Minimum gridunit difference between the held canvas and the viewport's own +## fit before a resize earns a refetch. A canvas smaller than the viewport +## letterboxes (visible empty margin); larger crops (harmless). Below this +## threshold the mismatch is a couple of screen pixels — not worth a derive. +const RESIZE_REFIT_MIN_CELL_DELTA: int = 4 + +## Pan drift thresholds, as a fraction of the held canvas's half-footprint. +## SOFT: the canvas edge is nearing the viewport centre — schedule a settled +## refetch, but keep panning the held canvas smoothly (no snap, no request +## storm while the key/edge is held). HARD: the pan is about to run past the +## canvas entirely, where waiting for a settle would show empty background — +## re-float immediately and accept the recentre. +const PAN_REFLOAT_SOFT_FRACTION: float = 0.5 +const PAN_REFLOAT_HARD_FRACTION: float = 0.9 const OVERLAY_DEFS: Array = [ { @@ -151,6 +181,15 @@ var _legend_panel = null var _request = null # StepCanvasRequest var _disk_sweep_timer: Timer = null # T-1183 coarse background sweep trigger +var _fetch_overlay: Control = null # StepCanvasFetchOverlay — drawn above the map + +## The shared settle timer (pair session 2026-07-26). One-shot, restarted by +## every resize notification and every soft pan-edge crossing; on timeout +## _on_refetch_settle() decides which refetch (if any) the view now needs. +## Both triggers fire many times per second while the user is moving — this +## collapses them into a single request at the resting state. +var _refetch_settle_timer: Timer = null + ## Test-injection-only (T-1193 first slice): threads into StepCanvasRequest's ## disk_cache_root seam so tests can isolate the Tier-2/3 disk cache from the ## machine-shared user://atlas_cache/ (one directory for every worktree gate, @@ -185,6 +224,24 @@ func _ready() -> void: _annotation_layer.name = "AnnotationLayer" _canvas.add_child(_annotation_layer) + # The fetch readout must paint ABOVE the terrain canvas. A Control's own + # _draw() runs BEFORE its children, so anything this class drew directly + # (the old DERIVING TERRAIN label, the pending wash) was painted underneath + # `_canvas` and only ever visible when no texture existed at all — the + # reason the old treatment read as "nothing is happening" during a slow + # fetch (pair session 2026-07-26: Jeroen saw no panel). This dedicated + # overlay is added AFTER _canvas, so it draws on top of the map. + _fetch_overlay = StepCanvasFetchOverlay.new() + _fetch_overlay.name = "FetchOverlay" + add_child(_fetch_overlay) + + _refetch_settle_timer = Timer.new() + _refetch_settle_timer.name = "RefetchSettleTimer" + _refetch_settle_timer.one_shot = true + _refetch_settle_timer.wait_time = REFETCH_SETTLE_S + _refetch_settle_timer.timeout.connect(_on_refetch_settle) + add_child(_refetch_settle_timer) + _request = StepCanvasRequest.new(self, disk_cache_root_override) _request.name = "Request" add_child(_request) @@ -409,6 +466,75 @@ func _request_extent() -> Vector2i: return StepCanvasTransport.cap_extent_to_body(fit, _held_rung, _global_body_extent) +## Resize refetch (pair session 2026-07-26, Jeroen: "the map dimension should +## also respond to screen resizes, both of the game as well as diegetic +## in-implant"). Debounce fired: if the viewport now wants a materially +## different canvas than the one we hold, request it. The held canvas keeps +## drawing until the new one lands (hold-and-swap), so a resize never blanks +## the map — it just stops being letterboxed once the refit arrives. +## +## Global is deliberately EXEMPT: its extent is the body's own grid, chosen +## server-side and ignored from the request, so a refetch would return the +## identical canvas. Resize is already handled correctly for Global by +## _recompute_canvas_transform()'s integer re-fit, which just ran. +func _schedule_refetch_settle() -> void: + if _refetch_settle_timer: + _refetch_settle_timer.start() # restart: the user is still moving + + +## Settle fired — the window stopped resizing and/or the pan stopped. Decide +## what the view now needs, in priority order: a pan re-float (the canvas is +## in the wrong PLACE) outranks a size refit (the canvas is the wrong SIZE), +## because a re-float re-requests at the new centre anyway and that request +## already carries the current viewport-fit extent. +func _on_refetch_settle() -> void: + if _held_rung == StepCanvasTransport.RUNG_GLOBAL: + return + if get_body_id().is_empty() or _held_extent == Vector2i.ZERO: + return + if _pan_drift_fraction() >= PAN_REFLOAT_SOFT_FRACTION: + _refloat_now() + return + _maybe_refit_to_viewport() + + +## How far the held canvas has been panned away from centre, as a fraction of +## its own half-footprint (the larger axis). 0.0 = centred; 1.0 = the canvas +## edge has reached the viewport centre. Shared by the soft/hard pan +## thresholds so both read the same number. +func _pan_drift_fraction() -> float: + var footprint: Vector2 = _terrain_layer.get_footprint_px() + if footprint == Vector2.ZERO: + return 0.0 + var half: Vector2 = footprint * 0.5 + var screen_center: Vector2 = get_rect().size * 0.5 + var drift: Vector2 = (_view_offset + half) - screen_center + var fx: float = absf(drift.x) / maxf(half.x, 0.0001) + var fy: float = absf(drift.y) / maxf(half.y, 0.0001) + return maxf(fx, fy) + + +## Re-request this rung centred on whatever world point is under the viewport +## centre right now. The held canvas keeps drawing until the new one lands; +## _on_canvas_ready()'s own _recompute_canvas_transform() re-centres then. +func _refloat_now() -> void: + var screen_center: Vector2 = get_rect().size * 0.5 + _world_center = StepCanvasTransport.canvas_local_to_world_m( + screen_center - _view_offset, _world_center, _held_rung, _held_extent + ) + _view_offset = Vector2.ZERO + _fire_request() + + +func _maybe_refit_to_viewport() -> void: + var wanted: Vector2i = _request_extent() + var dx: int = absi(wanted.x - _held_extent.x) + var dy: int = absi(wanted.y - _held_extent.y) + if maxi(dx, dy) < RESIZE_REFIT_MIN_CELL_DELTA: + return + _fire_request() + + func _on_step_canvas_received(response: Dictionary) -> void: _request.on_response(response) @@ -631,33 +757,35 @@ func _reset_to_global() -> void: queue_redraw() -## Pan-edge re-request: once the view has panned far enough that the held -## canvas's own edge would show, re-request the SAME rung at a new center — -## mirrors the retired viewer's own _maybe_refloat_window(), against the new -## wire's request shape. Global never re-requests on pan (its canvas is the -## whole body, D-255(a) — no edge to cross). +## Pan-edge re-request, called every frame from _apply_pan_delta(). Two +## thresholds (pair session 2026-07-26 — this used to fire the request the +## instant the soft threshold was crossed, from inside the per-frame pan +## loop, which issued a fresh request AND snapped the view on every crossing +## of a sustained edge-scroll): +## +## - SOFT: schedule the shared settle timer and keep panning the held +## canvas smoothly. Hold an edge-scroll for two seconds and exactly ONE +## request goes out — for where the pan actually ended, not for each +## waypoint it swept through. +## - HARD: the canvas edge is about to enter view, where waiting for a +## settle would show empty background — re-float immediately. +## +## Drifting back inside the soft threshold (panning back the way you came) +## cancels the pending settle: the view no longer wants a different canvas. +## Global never re-requests on pan (its canvas is the whole body, D-255(a) — +## no edge to cross). func _maybe_refloat() -> void: if _held_rung == StepCanvasTransport.RUNG_GLOBAL: return - var footprint: Vector2 = _terrain_layer.get_footprint_px() - if footprint == Vector2.ZERO: - return - var half: Vector2 = footprint * 0.5 - # view_offset is the SCREEN position of canvas-local (0,0) — the canvas's - # center in canvas-local space is `half`. Once panning has moved that - # point more than half the footprint away from screen-center, the edge - # is at or past the viewport's own center — time to re-float. - var screen_center: Vector2 = get_rect().size * 0.5 - var canvas_center_screen: Vector2 = _view_offset + half - var drift: Vector2 = canvas_center_screen - screen_center - if absf(drift.x) < half.x * 0.5 and absf(drift.y) < half.y * 0.5: - return - var new_center_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m( - screen_center - _view_offset, _world_center, _held_rung, _held_extent - ) - _world_center = new_center_world - _view_offset = Vector2.ZERO - _fire_request() + var fraction: float = _pan_drift_fraction() + if fraction >= PAN_REFLOAT_HARD_FRACTION: + if _refetch_settle_timer: + _refetch_settle_timer.stop() + _refloat_now() + elif fraction >= PAN_REFLOAT_SOFT_FRACTION: + _schedule_refetch_settle() + elif _refetch_settle_timer and not _refetch_settle_timer.is_stopped(): + _refetch_settle_timer.stop() # ============================================================================= @@ -675,26 +803,12 @@ func _draw() -> void: func _draw_border_fade() -> void: draw_rect(Rect2(_view_offset, get_rect().size), COLOR_BORDER_FADE) - _draw_deriving_label() func _draw_pending_wash() -> void: draw_rect(Rect2(_view_offset, get_rect().size), COLOR_PENDING_WASH) -func _draw_deriving_label() -> void: - var label := "DERIVING TERRAIN…" - var font := ThemeDB.fallback_font - var font_size := 20 - var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) - var viewport: Vector2 = get_rect().size - var center: Vector2 = viewport * 0.5 - var baseline: Vector2 = center - text_size * 0.5 + Vector2(0.0, text_size.y * 0.5) - draw_string( - font, baseline, label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size, COLOR_DERIVING_LABEL - ) - - func _apply_transform() -> void: _canvas.position = _view_offset queue_redraw() @@ -837,6 +951,14 @@ func _notification(what: int) -> void: if _legend_panel: _legend_panel.reposition() _recompute_canvas_transform() + # Re-center/re-fit the canvas we ALREADY hold (above) is only half the + # job: a canvas derived for a smaller viewport stays that size forever + # and letterboxes in the bigger window. Ask for one sized to the new + # viewport, debounced so a drag issues a single request at the size the + # window settles on. Fires for both resize sources — the OS window and + # a diegetic in-implant resize — since either reaches this Control as + # the same notification. + _schedule_refetch_settle() elif what == NOTIFICATION_APPLICATION_FOCUS_OUT: _app_has_focus = false elif what == NOTIFICATION_APPLICATION_FOCUS_IN: @@ -881,6 +1003,7 @@ func _handle_key(event: InputEventKey) -> void: func _process(delta: float) -> void: if not visible: return + _tick_fetch_overlay(delta) var direction: Vector2 = StepCanvasTransport.held_pan_direction() if _is_cursor_edge_scrolling(): direction += _edge_scroll_direction() @@ -889,6 +1012,18 @@ func _process(delta: float) -> void: _apply_pan_delta(direction, delta) +## Feed the fetch overlay: the viewer owns the request (and therefore the +## pending state), the overlay owns the rendering. Called every frame from +## _process() BEFORE the pan early-return, so the readout keeps animating +## while the player is not touching anything — which is exactly when a slow +## fetch happens. +func _tick_fetch_overlay(delta: float) -> void: + if _fetch_overlay == null: + return + var pending: bool = _request != null and _request.is_pending() + _fetch_overlay.tick(delta, pending) + + func _apply_pan_delta(direction: Vector2, delta: float) -> void: var normalized: Vector2 = direction.normalized() _view_offset -= normalized * PAN_SPEED_PX_S * delta