feat(ui): ImplantPending — diegetic Layer-1 "generating" indicator (#960)

A reusable implant-layer busy indicator (Araminta's spec): an animated
bracket sweep `[ >>>>···· ]` with a caption, themed entirely via ImplantTheme.
The Atlas shows it centered over the canvas while the Layer-1 proxy is Pending
and hides it on Ready — so cache hits never flash it and serverless mode never
shows it.

Layer separation (per direction): ImplantPending belongs to the diegetic
implant UI only. The global, non-diegetic UI layer must use its own busy
indicator with its own visuals — sharing the sweep logic is fine, resting on
this theme/these glyphs is not. Documented in the component header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 21:05:39 +02:00
co-authored by Claude Opus 4.7
parent 616f7e9f3b
commit 25ef9d2f63
3 changed files with 150 additions and 0 deletions
+8
View File
@@ -23,3 +23,11 @@ func test_generation_data_round_trips() -> void:
}
v.set_generation_layer1(mock)
assert_that(v.get_generation_layer1()).is_equal(mock)
func test_implant_pending_start_stop() -> void:
var p: ImplantPending = auto_free(ImplantPending.new())
p.start("GENERATING LAYER 1")
assert_bool(p.visible).is_true()
p.stop()
assert_bool(p.visible).is_false()
@@ -182,6 +182,7 @@ var _city_panel = null # ImplantPanel sidebar (city data)
var _empty_notice = null # ImplantPanel shown when heightmap missing
var _overlay_bar = null # #836 overlay toggle bar (no class_name, review #8)
var _screen_header: ImplantHeader = null # top-left title/hint (D-169 composition)
var _gen_pending_indicator: ImplantPending = null # #960 Layer-1 "generating" overlay
func _ready() -> void:
@@ -218,6 +219,14 @@ func _ready() -> void:
# #960: receive proxied Layer-1 generation output (D-225).
SimBridge.atlas_layers_received.connect(_on_atlas_layers_received)
# #960: diegetic "generating" indicator, shown only while the proxy is Pending.
_gen_pending_indicator = ImplantPending.new()
_gen_pending_indicator.name = "GenPending"
add_child(_gen_pending_indicator)
_gen_pending_indicator.apply_implant_theme(_implant_theme)
resized.connect(_position_pending_indicator)
_position_pending_indicator()
func _exit_tree() -> void:
if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received):
@@ -239,6 +248,8 @@ func show_body(body: Dictionary, system: Dictionary) -> void:
_generation_layer1 = null
_gen_pending = false
_gen_retries = 0
if _gen_pending_indicator:
_gen_pending_indicator.stop()
_request_generation_layers()
grab_focus()
queue_redraw()
@@ -306,15 +317,19 @@ func _on_atlas_layers_received(response: Dictionary) -> void:
"Ready":
_gen_pending = false
_gen_retries = 0
_set_gen_indicator(false)
set_generation_layer1(response.get("layer1"))
"Pending":
if _gen_retries < GEN_MAX_RETRIES:
_gen_retries += 1
_set_gen_indicator(true)
_schedule_gen_retry(body_id)
else:
_gen_pending = false # gave up — overlays stay empty
_set_gen_indicator(false)
_:
_gen_pending = false # NotFound / Error — nothing to draw
_set_gen_indicator(false)
func _schedule_gen_retry(body_id: String) -> void:
@@ -327,6 +342,27 @@ func _schedule_gen_retry(body_id: String) -> void:
)
## Show/hide the diegetic pending indicator. Starting only when not already
## visible avoids resetting the sweep on every re-poll.
func _set_gen_indicator(on: bool) -> void:
if _gen_pending_indicator == null:
return
if on:
if not _gen_pending_indicator.visible:
_position_pending_indicator()
_gen_pending_indicator.start("GENERATING LAYER 1")
else:
_gen_pending_indicator.stop()
func _position_pending_indicator() -> void:
if _gen_pending_indicator == null:
return
_gen_pending_indicator.position = Vector2(
(size.x - ImplantPending.DEFAULT_WIDTH) * 0.5, size.y * 0.45
)
func get_hovered_city() -> Dictionary:
return _hovered_city
+106
View File
@@ -0,0 +1,106 @@
class_name ImplantPending
extends Control
## Diegetic "pending / working" indicator for the implant overlay (D-169).
##
## An animated bracket sweep — `[ >>>>········ ]` — with a caption above it.
## Linear left-to-right fill that loops; reads as a bounded machine process,
## not an OS spinner (Araminta's spec, #960).
##
## LAYER SEPARATION: this belongs to the IMPLANT (diegetic, in-fiction) UI layer
## only. It styles exclusively from ImplantTheme. The global / non-diegetic UI
## layer must NOT reuse these visuals — if that layer needs a busy indicator it
## gets its own component with its own look. Sharing the sweep *logic* is fine;
## resting on the same theme or glyphs is not.
##
## API: start(label) shows + animates; stop() hides + resets. Starts hidden, so
## at rest the player sees nothing.
const SWEEP_PERIOD: float = 1.2 # seconds per left-to-right pass (linear, looping)
const INTERIOR_WIDTH: float = 160.0 # px between the brackets
const SWEEP_WIDTH: float = 28.0 # px width of the moving fill
const DEFAULT_WIDTH: float = 220.0
var _label_text: String = "WORKING"
var _sweep_t: float = 0.0 # normalized 0..1 sweep position
var _theme: ImplantTheme = null
var _font: Font = null
func _ready() -> void:
_font = ThemeDB.fallback_font
custom_minimum_size = Vector2(DEFAULT_WIDTH, 40.0)
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
set_process(false)
## Show the indicator and begin animating. `label` is uppercased for display.
func start(label: String = "WORKING") -> void:
_label_text = label.to_upper()
_sweep_t = 0.0
visible = true
set_process(true)
queue_redraw()
## Hide the indicator and stop animating.
func stop() -> void:
visible = false
set_process(false)
_sweep_t = 0.0
func apply_implant_theme(t: ImplantTheme) -> void:
_theme = t
queue_redraw()
func _process(delta: float) -> void:
_sweep_t = fmod(_sweep_t + delta / SWEEP_PERIOD, 1.0)
queue_redraw()
func _draw() -> void:
if _font == null:
return
var dim: Color = _theme.text_dim if _theme else Color("#667788")
var accent: Color = _theme.accent_active if _theme else Color("#f0d060")
accent.a = 0.6
var fs: int = _theme.font_body if _theme else 11
var line_h: float = float(_theme.line_height if _theme else 18)
var w: float = size.x if size.x > 0.0 else DEFAULT_WIDTH
# Caption — centered, dim.
var cap_w: float = _font.get_string_size(_label_text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x
draw_string(
_font,
Vector2((w - cap_w) * 0.5, float(fs)),
_label_text,
HORIZONTAL_ALIGNMENT_LEFT,
-1,
fs,
dim
)
# Bracket row — centered group: [ <interior> ]
var glyph_w: float = _font.get_string_size("[", HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x
var group_w: float = glyph_w * 2.0 + INTERIOR_WIDTH
var gx: float = (w - group_w) * 0.5
var baseline_y: float = line_h + float(fs)
draw_string(_font, Vector2(gx, baseline_y), "[", HORIZONTAL_ALIGNMENT_LEFT, -1, fs, dim)
draw_string(
_font,
Vector2(gx + glyph_w + INTERIOR_WIDTH, baseline_y),
"]",
HORIZONTAL_ALIGNMENT_LEFT,
-1,
fs,
dim
)
# Sweep fill — a thin bar travelling across the bracket interior.
var interior_x: float = gx + glyph_w
var travel: float = INTERIOR_WIDTH - SWEEP_WIDTH
var sweep_x: float = interior_x + _sweep_t * travel
var sweep_y: float = baseline_y - float(fs) * 0.35
draw_rect(Rect2(sweep_x, sweep_y, SWEEP_WIDTH, 1.0), accent)