From fb7cd357b87a9250d1966f2fef512752180251f2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 20 Apr 2026 20:28:02 +0200 Subject: [PATCH] feat(ui): location picker in Bookmark tab (Workstream 7, #680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../character_creation/character_creation.gd | 96 ++++++++++++++++++- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/client/ui/meta/screens/character_creation/character_creation.gd b/client/ui/meta/screens/character_creation/character_creation.gd index ca427fc70..86f4ee84e 100644 --- a/client/ui/meta/screens/character_creation/character_creation.gd +++ b/client/ui/meta/screens/character_creation/character_creation.gd @@ -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) # =============================================================================