class_name BrowserIndexScreen extends Control ## Index screen for one entity kind in the data browser (T-1133, D-254 §4). ## A scrollable ImplantDataRow list (primary + secondary summary column), ## filterable by a live-typed search string. One instance handles all six ## kinds — parameterized by set_kind(), not six near-duplicate screens. ## ## Filtering (D-254 §4: "filterable/searchable by name"): typed characters ## append to the search buffer and re-filter live; Backspace removes the ## last character; the implant has no OS textbox, so this is the same ## "keyboard IS the input surface" convention the Atlas/Economics apps ## already use for navigation. signal row_selected(entity_id: String) signal back_requested const COLOR_BG: Color = Color("#0d1117") const PANEL_WIDTH: float = 460.0 const PANEL_MARGIN: float = 16.0 const LIST_MAX_HEIGHT: float = 520.0 var _kind: String = "" var _all_rows: Array = [] # BrowserAdapter row view-models, unfiltered var _filtered_rows: Array = [] var _selected_idx: int = 0 var _search: String = "" var _loading: bool = false var _error: String = "" var _panel = null # ImplantPanel var _scroll: ScrollContainer = null var _list_box: VBoxContainer = null var _implant_theme = null # ImplantTheme var _row_labels: Array = [] # Array[ImplantDataRow], one per filtered row var _pending: ImplantPending = null func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_STOP set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) func _draw() -> void: draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) func setup(implant_theme) -> void: _implant_theme = implant_theme _build_panel() _build_pending(implant_theme) ## Set the entity kind this screen shows and (re)issue the index request. ## filter_system_id is forwarded to SimBridge.request_browse_index — only ## meaningful for kind == BrowserAdapter.KIND_BODY. func set_kind(kind: String, filter_system_id: String = "") -> void: _kind = kind _all_rows = [] _filtered_rows = [] _selected_idx = 0 _search = "" _error = "" _loading = true _rebuild_list() if _pending: _pending.start("QUERYING " + BrowserAdapter.kind_label(kind)) SimBridge.request_browse_index(kind, filter_system_id) ## Feed a decoded BrowseResponse (SimBridge.browse_response_received handler, ## owned by BrowserApp) into this screen. Ignored if the response's kind ## doesn't match what this screen currently shows (a stale response arriving ## after the player already navigated elsewhere). func receive_response(response: Dictionary) -> void: if response.get("kind", "") != _kind: return _loading = false if _pending: _pending.stop() var status: String = BrowserAdapter.response_status(response) if status == "Error": _error = BrowserAdapter.response_error(response) _all_rows = [] else: _error = "" _all_rows = BrowserAdapter.index_rows(response) _apply_filter() func enter(payload: Dictionary) -> void: var kind: String = payload.get("kind", "") var filter_system_id: String = payload.get("filter_system_id", "") if kind != _kind or _all_rows.is_empty(): set_kind(kind, filter_system_id) else: _rebuild_list() func leave() -> void: pass func current_kind() -> String: return _kind func has_selection() -> bool: return not _filtered_rows.is_empty() func selected_entity_id() -> String: if _filtered_rows.is_empty(): return "" _selected_idx = clampi(_selected_idx, 0, _filtered_rows.size() - 1) return str(_filtered_rows[_selected_idx].get("id", "")) func trigger_enter() -> void: if has_selection(): row_selected.emit(selected_entity_id()) func navigate(delta: int) -> void: if _filtered_rows.is_empty(): return _selected_idx = wrapi(_selected_idx + delta, 0, _filtered_rows.size()) _rebuild_list() ## Append a character to the live search filter. Called from BrowserApp's ## key handler for printable keys while this screen is current. func append_search_char(ch: String) -> void: _search += ch _apply_filter() func backspace_search() -> void: if _search.is_empty(): return _search = _search.substr(0, _search.length() - 1) _apply_filter() func clear_search() -> void: if _search.is_empty(): return _search = "" _apply_filter() func has_search_text() -> bool: return not _search.is_empty() func _apply_filter() -> void: _filtered_rows = BrowserAdapter.filter_rows(_all_rows, _search) _selected_idx = 0 _rebuild_list() # ============================================================================= # Panel / list # ============================================================================= func _build_panel() -> void: _panel = ImplantPanel.new() _panel.name = "IndexPanel" _panel.theme_resource = _implant_theme _panel.custom_minimum_size.x = PANEL_WIDTH _panel.mouse_filter = Control.MOUSE_FILTER_IGNORE _panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN) add_child(_panel) func _build_pending(implant_theme) -> void: _pending = ImplantPending.new() _pending.apply_implant_theme(implant_theme) _pending.position = Vector2(PANEL_MARGIN + PANEL_WIDTH + 24.0, PANEL_MARGIN) add_child(_pending) func _rebuild_list() -> void: if not _panel: return _panel.clear() _row_labels.clear() var subtitle: String = "%d / %d" % [_filtered_rows.size(), _all_rows.size()] _panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), subtitle)) _panel.add_component(ImplantSeparator.new()) var search_line: String = "search " + (_search if not _search.is_empty() else "—") _panel.add_component(ImplantDataRow.new(search_line)) _panel.add_component(ImplantSeparator.new()) if _loading: _panel.add_component(ImplantTextBlock.new("querying registry…")) elif not _error.is_empty(): _panel.add_component(ImplantTextBlock.new("[ERROR] " + _error)) elif _filtered_rows.is_empty(): var msg: String = "no matches" if not _search.is_empty() else "no entries" _panel.add_component(ImplantTextBlock.new(msg)) else: _build_scroll_list() _panel.add_component(ImplantSeparator.new()) _panel.add_component( ImplantTextBlock.new("↑ ↓ select enter open type search esc back") ) func _build_scroll_list() -> void: _scroll = ScrollContainer.new() _scroll.custom_minimum_size = Vector2(PANEL_WIDTH, minf(LIST_MAX_HEIGHT, _list_natural_height())) _scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _panel.add_component(_scroll) _list_box = VBoxContainer.new() _list_box.add_theme_constant_override("separation", 0) _list_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL _scroll.add_child(_list_box) for i: int in range(_filtered_rows.size()): var row: Dictionary = _filtered_rows[i] var primary: String = str(row.get("primary", "")) var secondary: String = str(row.get("secondary", "")) var text: String = primary if not secondary.is_empty(): text += " · " + secondary var prefix: String = "▸ " if i == _selected_idx else " " var label := ImplantDataRow.new(prefix + text) if _implant_theme: label.apply_implant_theme(_implant_theme) _list_box.add_child(label) _row_labels.append(label) _scroll_to_selected() func _list_natural_height() -> float: var line_h: float = float(_implant_theme.line_height) if _implant_theme else 18.0 return float(_filtered_rows.size()) * line_h func _scroll_to_selected() -> void: if not _scroll or _row_labels.is_empty(): return _selected_idx = clampi(_selected_idx, 0, _row_labels.size() - 1) var target: Control = _row_labels[_selected_idx] # ensure_control_visible needs one layout pass to have valid rects on a # freshly-built list — defer so the ScrollContainer has sized itself. _scroll.ensure_control_visible.call_deferred(target)