diff --git a/client/tests/test_atlas_overlays.gd b/client/tests/test_atlas_overlays.gd index 6151f469a..5333c649c 100644 --- a/client/tests/test_atlas_overlays.gd +++ b/client/tests/test_atlas_overlays.gd @@ -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() diff --git a/client/ui/implant/apps/atlas/atlas_viewer.gd b/client/ui/implant/apps/atlas/atlas_viewer.gd index c879945ae..115b334d8 100644 --- a/client/ui/implant/apps/atlas/atlas_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_viewer.gd @@ -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 diff --git a/client/ui/implant/implant_pending.gd b/client/ui/implant/implant_pending.gd new file mode 100644 index 000000000..3b72440ab --- /dev/null +++ b/client/ui/implant/implant_pending.gd @@ -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: [ ] + 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)