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>
160 lines
5.3 KiB
GDScript
160 lines
5.3 KiB
GDScript
class_name BrowserApp
|
|
extends ImplantApp
|
|
## Data browser implant app (T-1133, D-254 §4). A SEPARATE app from the Atlas
|
|
## (implant/map) — the six registry entities do not share the Atlas's
|
|
## geographic drill-down (D-254 SS4, Jeroen's IA ruling). Registered as
|
|
## "implant/browser" in FULLSCREEN mode, key B.
|
|
##
|
|
## Nav: kind menu (root) -> index (per kind, scrollable + searchable) ->
|
|
## detail (per entity). Same push/pop convention the Atlas app uses.
|
|
##
|
|
## Composed entirely from D-169 implant components via the three screen
|
|
## classes below (BrowserKindMenuScreen/BrowserIndexScreen/BrowserDetailScreen)
|
|
## — no new UI primitives.
|
|
|
|
var _kind_menu_screen = null # BrowserKindMenuScreen
|
|
var _index_screen = null # BrowserIndexScreen
|
|
var _detail_screen = null # BrowserDetailScreen
|
|
|
|
|
|
func _ready() -> void:
|
|
manifest = load("res://ui/implant/apps/browser/app.tres")
|
|
super._ready()
|
|
|
|
|
|
func on_install() -> void:
|
|
var implant_theme = load("res://ui/implant/default_implant.tres")
|
|
|
|
_kind_menu_screen = BrowserKindMenuScreen.new()
|
|
_kind_menu_screen.setup(implant_theme)
|
|
_kind_menu_screen.kind_selected.connect(_on_kind_selected)
|
|
register_screen("kind_menu", _kind_menu_screen)
|
|
|
|
_index_screen = BrowserIndexScreen.new()
|
|
_index_screen.setup(implant_theme)
|
|
_index_screen.row_selected.connect(_on_row_selected)
|
|
register_screen("index", _index_screen)
|
|
|
|
_detail_screen = BrowserDetailScreen.new()
|
|
_detail_screen.setup(implant_theme)
|
|
register_screen("detail", _detail_screen)
|
|
|
|
SimBridge.browse_response_received.connect(_on_browse_response_received)
|
|
|
|
nav.set_default("kind_menu")
|
|
|
|
|
|
func _unhandled_key_input(event: InputEvent) -> void:
|
|
if not event is InputEventKey:
|
|
return
|
|
if manifest == null or not HudGroups.is_app_active(manifest.app_path):
|
|
return
|
|
if not event.is_pressed() or event.is_echo():
|
|
return
|
|
_handle_key(event as InputEventKey)
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
|
func _handle_key(event: InputEventKey) -> void:
|
|
# Search-mode: on the index screen, printable keys type into the live
|
|
# filter instead of being interpreted as navigation. Split into its own
|
|
# function (not inlined here) — it is a genuinely separate input mode
|
|
# from the top-level app navigation below, and keeping it here would
|
|
# push this function's branch/return count well past what one function
|
|
# should hold.
|
|
if current_screen_id() == "index" and _index_screen:
|
|
_handle_index_search_key(event)
|
|
return
|
|
|
|
match event.physical_keycode:
|
|
KEY_B:
|
|
HudGroups.close_app()
|
|
KEY_ESCAPE:
|
|
_close_or_pop()
|
|
KEY_ENTER, KEY_KP_ENTER:
|
|
_handle_enter()
|
|
KEY_UP:
|
|
if current_screen_id() == "kind_menu" and _kind_menu_screen:
|
|
_kind_menu_screen.navigate(-1)
|
|
KEY_DOWN:
|
|
if current_screen_id() == "kind_menu" and _kind_menu_screen:
|
|
_kind_menu_screen.navigate(1)
|
|
|
|
|
|
## Key handling while the index screen is current. Arrows/enter/escape/
|
|
## backspace are the always-active control keys; everything else in the
|
|
## printable range (D-254 §4: "filterable/searchable by name") appends to
|
|
## the live search buffer. This mirrors the implant's existing
|
|
## keyboard-as-input-surface convention (no OS textbox anywhere in this
|
|
## component library) — there is no separate "focus the search box" step.
|
|
func _handle_index_search_key(event: InputEventKey) -> void:
|
|
match event.physical_keycode:
|
|
KEY_UP:
|
|
_index_screen.navigate(-1)
|
|
return
|
|
KEY_DOWN:
|
|
_index_screen.navigate(1)
|
|
return
|
|
KEY_ENTER, KEY_KP_ENTER:
|
|
_index_screen.trigger_enter()
|
|
return
|
|
KEY_ESCAPE:
|
|
if _index_screen.has_search_text():
|
|
_index_screen.clear_search()
|
|
else:
|
|
_close_or_pop()
|
|
return
|
|
KEY_BACKSPACE:
|
|
_index_screen.backspace_search()
|
|
return
|
|
|
|
# event.unicode carries the actual typed character (shift/caps already
|
|
# resolved by the platform), NOT the physical keycode — String.chr() is
|
|
# GDScript's codepoint-to-one-char-String conversion. Printable range
|
|
# only (32 = space .. 126 = ~): control characters (arrows, tab, etc.
|
|
# already handled above by physical_keycode) report unicode == 0 or a
|
|
# non-printable codepoint and must not leak into the search buffer.
|
|
if event.unicode >= 32 and event.unicode < 127:
|
|
_index_screen.append_search_char(String.chr(event.unicode))
|
|
|
|
|
|
func _close_or_pop() -> void:
|
|
if current_screen_id() == "kind_menu":
|
|
HudGroups.close_app()
|
|
else:
|
|
nav.pop()
|
|
|
|
|
|
func _handle_enter() -> void:
|
|
match current_screen_id():
|
|
"kind_menu":
|
|
if _kind_menu_screen:
|
|
_kind_menu_screen.trigger_enter()
|
|
|
|
|
|
# =============================================================================
|
|
# Signal handlers
|
|
# =============================================================================
|
|
|
|
|
|
func _on_kind_selected(kind: String) -> void:
|
|
nav.push("index", {"kind": kind, "filter_system_id": ""})
|
|
|
|
|
|
func _on_row_selected(entity_id: String) -> void:
|
|
var kind: String = _index_screen.current_kind() if _index_screen else ""
|
|
nav.push("detail", {"kind": kind, "entity_id": entity_id})
|
|
|
|
|
|
func _on_browse_response_received(response: Dictionary) -> void:
|
|
if response == null:
|
|
return
|
|
# Fan out to whichever screen is currently waiting on a response for this
|
|
# kind — both screens no-op via their own kind-match guard if the
|
|
# response isn't theirs (a stale one from a prior navigation, or one
|
|
# meant for the other screen type).
|
|
if _index_screen:
|
|
_index_screen.receive_response(response)
|
|
if _detail_screen:
|
|
_detail_screen.receive_response(response)
|