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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user