Files
settled-reach/client/ui/implant/apps/browser/browser_app.gd
T
jpmschweitzerandClaude Fable 5 90c562a6a3 feat(ui): T-1133 data browser — implant/browser app, six index+detail screens (D-254 SS4)
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>
2026-07-17 10:24:39 +02:00

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)