feat(ui): location picker in Bookmark tab (Workstream 7, #680)

Adds the starting-location picker as a sub-component of the Bookmark
tab detail view, per Araminta's spec and D-128's "culture implicit in
location" constraint. Fills the space W6 reserved below the CAREER /
CAPITAL data rows.

Picker structure:
- "STARTING LOCATION" section label (DIM_TEXT, 10px, all-caps).
- ScrollContainer min_height=80 → VBoxContainer of selectable items.
- Each item: Button with child VBox carrying the location name Label
  (PRIMARY_TEXT, 12px) and an optional culture tag Label (DIM_TEXT,
  10px, mouse_filter IGNORE per D-128). Culture label is NOT rendered
  when `allowed_locations_cultures[i]` is empty — no "Unknown"
  placeholder, the row just shrinks.

Behavior:
- Clicking a bookmark card auto-selects its default_location in the
  picker (handled via _selected_location_id + _update_location_selection).
- Clicking a location item updates _selected_location_id and re-gates
  the Start button (already checked in W6).
- Switching bookmarks rebuilds the picker list for the new
  allowed_locations; prior selection cleared.
- Parallel-array length mismatch defended: reads
  `cultures[i] if i < cultures.size() else ""` so a short cultures
  array won't crash rendering.

D-128 compliance:
- No culture dropdown or filter anywhere.
- Culture tag Label is strictly display: MOUSE_FILTER_IGNORE, no
  signal handlers.
- CharacterProfile carries only start_location_id; no culture_id.

Other: removes W6's placeholder "Starting Location: X" ImplantDataRow
since the picker supersedes it; separator before the picker preserved.

Verification:
- gdlint clean
- Headless parse + scanner check (widened per hardened pr-push skill):
  no new errors. Pre-existing autoload class_name noise filtered per
  CLAUDE.md.
- test_character_creation_sprint28 88/88, test_protocol 62/62,
  test_implant_* all green, test_client_p3 24/24,
  test_ui_framework_sprint15 54/54.

Workstream 8 (Skills tab stub content) lands next — #618 closes then.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 20:28:02 +02:00
co-authored by Claude Opus 4.6
parent 804bba6ae8
commit fb7cd357b8
@@ -273,12 +273,16 @@ var _appearance_active_idx: int = 0
var _appearance_sub_btns: Array[Button] = []
var _appearance_sub_sections: Array[Control] = []
# --- Bookmark tab state (WS6) ---
# --- Bookmark tab state (WS6/WS7) ---
var _selected_bookmark_id: String = ""
var _selected_location_id: String = ""
var _bookmark_cards: Array[Button] = []
var _bookmark_detail_panel: ImplantPanel = null
var _bookmark_cards_vbox: VBoxContainer = null
var _location_items: Array[Button] = []
var _location_item_ids: Array[String] = []
var _location_item_labels: Array[Label] = []
var _location_vbox: VBoxContainer = null
# --- Asset manifest (loaded once, replaces filesystem scanning) ---
var _manifest: Dictionary = {}
@@ -739,6 +743,11 @@ func _update_bookmark_card_selection() -> void:
func _refresh_bookmark_detail(bm: Dictionary) -> void:
_bookmark_detail_panel.clear()
_location_items.clear()
_location_item_ids.clear()
_location_item_labels.clear()
_location_vbox = null
if bm.is_empty():
_bookmark_detail_panel.add_component(
ImplantHeader.new("Select a Bookmark", "Choose your starting conditions")
@@ -757,9 +766,9 @@ func _refresh_bookmark_detail(bm: Dictionary) -> void:
ImplantDataRow.new("Starting Capital: %d tr" % int(capital), TEXT_COLOR)
)
var default_loc: String = bm.get("default_location", "")
if not default_loc.is_empty():
_bookmark_detail_panel.add_component(ImplantDataRow.new("Starting Location: " + default_loc, TEXT_DIM))
# Location picker (WS7) — replaces the single "Starting Location: X" DataRow
_bookmark_detail_panel.add_component(ImplantSeparator.new())
_build_location_picker(bm)
var flavor: String = bm.get("flavor", "")
if not flavor.is_empty():
@@ -771,6 +780,85 @@ func _update_start_btn_state() -> void:
_footer_start.disabled = _selected_bookmark_id.is_empty() or _selected_location_id.is_empty()
func _build_location_picker(bm: Dictionary) -> void:
var allowed: Array = bm.get("allowed_locations", [])
var cultures: Array = bm.get("allowed_locations_cultures", [])
var section_lbl := Label.new()
section_lbl.text = "STARTING LOCATION"
section_lbl.add_theme_color_override("font_color", TEXT_DIM)
section_lbl.add_theme_font_size_override("font_size", 10)
section_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
_bookmark_detail_panel.add_component(section_lbl)
if allowed.is_empty():
return
var scroll := ScrollContainer.new()
scroll.custom_minimum_size = Vector2(0, 80)
scroll.size_flags_horizontal = Control.SIZE_FILL
_bookmark_detail_panel.add_component(scroll)
_location_vbox = VBoxContainer.new()
_location_vbox.size_flags_horizontal = Control.SIZE_FILL
_location_vbox.add_theme_constant_override("separation", 2)
scroll.add_child(_location_vbox)
for i in allowed.size():
var loc_id: String = allowed[i]
var culture: String = cultures[i] if i < cultures.size() else ""
var btn := Button.new()
btn.size_flags_horizontal = Control.SIZE_FILL
btn.flat = false
btn.focus_mode = Control.FOCUS_NONE
btn.pressed.connect(_on_location_selected.bind(loc_id))
var vbox := VBoxContainer.new()
vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_theme_constant_override("separation", 1)
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
btn.add_child(vbox)
var name_lbl := Label.new()
name_lbl.text = loc_id
name_lbl.add_theme_color_override("font_color", TEXT_COLOR)
name_lbl.add_theme_font_size_override("font_size", 12)
name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(name_lbl)
if not culture.is_empty():
var culture_lbl := Label.new()
culture_lbl.text = culture
culture_lbl.add_theme_color_override("font_color", TEXT_DIM)
culture_lbl.add_theme_font_size_override("font_size", 10)
culture_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(culture_lbl)
_location_vbox.add_child(btn)
_location_items.append(btn)
_location_item_ids.append(loc_id)
_location_item_labels.append(name_lbl)
_update_location_selection()
func _on_location_selected(loc_id: String) -> void:
_selected_location_id = loc_id
_update_location_selection()
_update_start_btn_state()
func _update_location_selection() -> void:
for i in _location_items.size():
var selected: bool = (i < _location_item_ids.size() and _location_item_ids[i] == _selected_location_id)
_set_item_selected(_location_items[i], selected)
if i < _location_item_labels.size() and is_instance_valid(_location_item_labels[i]):
_location_item_labels[i].add_theme_color_override(
"font_color", HIGHLIGHT_COLOR if selected else TEXT_COLOR
)
# =============================================================================
# Tab: Body (Task #2)
# =============================================================================