New implant app (app_path implant/browser, key B, fullscreen), sibling
to the Atlas per Jeroen's IA ruling: kind picker -> generic filterable
index (live search-mode typing) -> generic detail, parameterized per
kind, composed entirely from D-169 components. available_in_companion
left unset (default true) — the app appears in the companion shell
automatically via the generic-host seam, zero companion-side wiring.
browser_adapter.gd is the sole home of literal wire field names: maps
Oscar's BrowseResponse contract ({id, primary, secondary} index rows;
BrowseDetail enum-as-single-key-map) to view models for all six kinds,
folding join partners (system economy/factions/culture, corporation
presence, commodity production chains with nested Leontief inputs).
browse_protocol.gd split out of protocol.gd (max-file-lines);
sim_bridge gains browse_response_received + request_browse_index/detail.
Live-data catch: a present-but-NULL key (unnamed asteroid belt
proper_name) bypasses Dictionary.get fallbacks and rendered '<null>' —
_display_or() null-vs-absent helper applied across all six detail
mappers, 4 regression tests distinct from the absent-key cases.
43 gdUnit adapter cases; full suite 3074 green. Live-verified against
a real server + real systems.db: all six kinds Ready with real row
counts (301/3240/466/165/36/28), detail drill-down, NotFound on bogus
ids. Spawn-mode DB resolution issue found during verification is
pre-existing (cwd-relative data/systems.db) — server-side fix follows
separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
254 lines
7.5 KiB
GDScript
254 lines
7.5 KiB
GDScript
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)
|