extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd") const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd") const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd") const CreditsBadge := preload("res://scripts/ui/credits_badge.gd") const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg") const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg") const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg") const LABS_LIGHT_ICON := preload("res://assets/icons/medmind/labs-light.svg") const REPORT_QUESTION_DARK_ICON := preload("res://assets/icons/medmind/report-question-dark.svg") const REPORT_QUESTION_LIGHT_ICON := preload("res://assets/icons/medmind/report-question-light.svg") const FLAG_DARK_ICON := preload("res://assets/icons/medmind/flag-dark.svg") const FLAG_LIGHT_ICON := preload("res://assets/icons/medmind/flag-light.svg") const BADGE_CIRCLE_OUTLINE_ICON := preload("res://assets/icons/medmind/badge-circle-outline.svg") const BADGE_CIRCLE_FILLED_ICON := preload("res://assets/icons/medmind/badge-circle-filled.svg") const COIN_ICON := preload("res://assets/icons/lucide/star.svg") const FEEDBACK_FADE_SECONDS := 0.45 const CORRECT_ADVANCE_DELAY_SECONDS := 1.25 const TEXT_SCALE_MIN := 0.85 const TEXT_SCALE_MAX := 1.35 const TEXT_SCALE_STEP := 0.1 const REPORT_DETAILS_MIN := 10 const REPORT_DETAILS_MAX := 2000 const MEDIA_ZOOM_MIN := 0.25 const MEDIA_ZOOM_MAX := 4.0 const MEDIA_ZOOM_FACTOR := 1.25 const REPORT_CATEGORIES := [ {"id": "clarity", "label": "Clarity"}, {"id": "medical_accuracy", "label": "Medical accuracy"}, {"id": "answer_explanation", "label": "Answer / explanation"}, {"id": "formatting_media", "label": "Formatting / media"}, {"id": "technical_issue", "label": "Technical issue"}, {"id": "other", "label": "Other"} ] var main: Control var theme_service var choice_buttons: Array[Button] = [] var choice_rows: Array[VBoxContainer] = [] var choice_percent_labels: Array[Label] = [] var choice_top_labels: Array[RichTextLabel] = [] var choice_followup_panels: Array[PanelContainer] = [] var choice_followup_contents: Array[VBoxContainer] = [] var eliminated: Array = [] var locked_wrong_choice_ids: Array = [] var followup_states := {} var followup_attempt_order: Array = [] var active_followup_choice_id := "" var followup_required := false var followup_answered := false var flagged := false var selected_choice := "" var current_answer := {} var question_highlights := {} var explanation_highlights := {} var reported_question_ids := [] var stem_plain_text := "" var explanation_plain_text := "" var highlight_drag_source := "" var timer_color_state := "" var last_timer_bar_value := -1.0 var last_timer_display_seconds := -1 var pending_correct_advance := false var question_text_scale := 1.0 var presented_choices: Array = [] var awaiting_next_question := false var simulation_submit_pending := false var progress_button: Button var progress_bar: ProgressBar var progress_percent_label: Label var bottom_progress_label: Label var bottom_progress_bar: ProgressBar var header_wordmark_label: Label var category_label: Label var timer_label: Label var timer_caption_label: Label var stem_label: RichTextLabel var lead_label: Label var explanation_label: RichTextLabel var references_container: VBoxContainer var correct_review_panel: PanelContainer var teaching_cards: HFlowContainer var review_followups_section: VBoxContainer var review_followups_content: VBoxContainer var review_followup_states: Array = [] var question_scroll: ScrollContainer var question_elo_label: Label var next_button: Button var skip_button: Button var text_smaller_button: Button var text_larger_button: Button var labs_button: Button var calculator_button: Button var exit_button: Button var clear_highlight_button: Button var flag_button: Button var report_button: Button var notes_button: Button var flashcard_button: Button var flashcard_prefill_dialog: Control var bookmark_button: Button var app_feedback_button: Button var theme_button: Button var credits_badge: PanelContainer var question_toolbar: HFlowContainer var header_stack: VBoxContainer var header_row: HBoxContainer var header_brand: HBoxContainer var header_toolbar_host: HBoxContainer var nav_palette: GridContainer var navigator_popup: PopupPanel var question_card: PanelContainer var question_panel: PanelContainer var actions: HBoxContainer var timer_footer: HBoxContainer var question_content: VBoxContainer var media_container: VBoxContainer var timer_bar: ProgressBar var choice_badges: Array[Label] = [] var choice_badge_textures: Array[TextureRect] = [] var choice_selected_icons: Array[TextureRect] = [] var feedback_fog: ColorRect var labs_overlay: Control var labs_panel: PanelContainer var labs_content: VBoxContainer var calculator_panel var report_overlay: Control var report_panel: PanelContainer var report_form_content: VBoxContainer var report_success_content: VBoxContainer var report_category_group: ButtonGroup var report_category_buttons: Array[CheckBox] = [] var report_details: TextEdit var report_char_count: Label var report_error_label: Label var report_submit_button: Button var report_cancel_button: Button var report_success_label: Label var report_is_submitting := false var report_question_id := "" var media_requests: Array[HTTPRequest] = [] var question_media_items: Array = [] var media_viewer_overlay: Control var media_viewer_background: ColorRect var media_viewer_scroll: ScrollContainer var media_viewer_center: CenterContainer var media_viewer_texture: TextureRect var media_viewer_position_label: Label var media_viewer_zoom_label: Label var media_viewer_previous_button: Button var media_viewer_next_button: Button var media_viewer_index := -1 var media_viewer_zoom := 1.0 var media_viewer_dragging := false var media_viewer_drag_position := Vector2.ZERO var red_fog_time := 0.0 var green_fog_time := 0.0 var correct_advance_delay := 0.0 var shake_time := 0.0 var shake_origin := Vector2.ZERO var untimed_elapsed_start_msec := 0 func _ready() -> void: theme_service = get_node("/root/UIThemeService") question_text_scale = _synced_question_text_scale() if not AccountProfileService.profile_changed.is_connected(_on_account_profile_changed): AccountProfileService.profile_changed.connect(_on_account_profile_changed) set_anchors_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 10) if not QuestionBankService.choice_percentages_updated.is_connected(_on_choice_percentages_updated): QuestionBankService.choice_percentages_updated.connect(_on_choice_percentages_updated) if not QuestionBankService.question_rating_updated.is_connected(_on_question_rating_updated): QuestionBankService.question_rating_updated.connect(_on_question_rating_updated) if not QuestionBankService.question_skipped.is_connected(_on_question_skipped): QuestionBankService.question_skipped.connect(_on_question_skipped) if not QuestionBankService.question_report_submitted.is_connected(_on_question_report_submitted): QuestionBankService.question_report_submitted.connect(_on_question_report_submitted) if not QuestionBankService.question_report_failed.is_connected(_on_question_report_failed): QuestionBankService.question_report_failed.connect(_on_question_report_failed) if not DynamicQuestionService.question_loaded.is_connected(_on_dynamic_question_loaded): DynamicQuestionService.question_loaded.connect(_on_dynamic_question_loaded) if not DynamicQuestionService.request_failed.is_connected(_on_dynamic_question_failed): DynamicQuestionService.request_failed.connect(_on_dynamic_question_failed) if not DynamicQuestionService.report_submitted.is_connected(_on_question_report_submitted): DynamicQuestionService.report_submitted.connect(_on_question_report_submitted) if not DynamicQuestionService.report_failed.is_connected(_on_question_report_failed): DynamicQuestionService.report_failed.connect(_on_question_report_failed) if not QuestionBankService.questions_changed.is_connected(_on_random_question_changed): QuestionBankService.questions_changed.connect(_on_random_question_changed) if not QuestionBankService.questions_load_failed.is_connected(_on_random_question_failed): QuestionBankService.questions_load_failed.connect(_on_random_question_failed) if not ExamSimulationService.state_changed.is_connected(_on_simulation_state_changed): ExamSimulationService.state_changed.connect(_on_simulation_state_changed) if not ExamSimulationService.request_failed.is_connected(_on_simulation_request_failed): ExamSimulationService.request_failed.connect(_on_simulation_request_failed) if not BookmarkService.status_changed.is_connected(_on_bookmark_status_changed): BookmarkService.status_changed.connect(_on_bookmark_status_changed) if not BookmarkService.question_loaded.is_connected(_on_bookmark_question_loaded): BookmarkService.question_loaded.connect(_on_bookmark_question_loaded) if not BookmarkService.question_failed.is_connected(_on_bookmark_question_failed): BookmarkService.question_failed.connect(_on_bookmark_question_failed) if not ReviewQueueService.question_loaded.is_connected(_on_review_question_loaded): ReviewQueueService.question_loaded.connect(_on_review_question_loaded) if not ReviewQueueService.question_failed.is_connected(_on_review_question_failed): ReviewQueueService.question_failed.connect(_on_review_question_failed) if not ReviewQueueService.pass_completed.is_connected(_on_review_pass_completed): ReviewQueueService.pass_completed.connect(_on_review_pass_completed) question_card = PanelContainer.new() question_card.size_flags_horizontal = Control.SIZE_EXPAND_FILL question_card.size_flags_vertical = Control.SIZE_EXPAND_FILL add_child(question_card) var card_margin := MarginContainer.new() card_margin.add_theme_constant_override("margin_left", 18) card_margin.add_theme_constant_override("margin_right", 18) card_margin.add_theme_constant_override("margin_top", 14) card_margin.add_theme_constant_override("margin_bottom", 12) question_card.add_child(card_margin) var card_content := VBoxContainer.new() card_content.add_theme_constant_override("separation", 10) card_margin.add_child(card_content) header_stack = VBoxContainer.new() header_stack.add_theme_constant_override("separation", 8) card_content.add_child(header_stack) header_brand = HBoxContainer.new() header_brand.add_theme_constant_override("separation", 8) header_stack.add_child(header_brand) var header_brand_mark := TextureRect.new() header_brand_mark.texture = Brand.ranaze_mark_texture() header_brand_mark.custom_minimum_size = Vector2(28, 28) header_brand_mark.expand_mode = TextureRect.EXPAND_IGNORE_SIZE header_brand_mark.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED header_brand.add_child(header_brand_mark) header_wordmark_label = Label.new() header_wordmark_label.text = "RanaZe" header_wordmark_label.add_theme_font_override("font", Brand.font) header_wordmark_label.add_theme_font_size_override("font_size", 18) header_brand.add_child(header_wordmark_label) var header_brand_spacer := Control.new() header_brand_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL header_brand.add_child(header_brand_spacer) header_row = HBoxContainer.new() header_row.add_theme_constant_override("separation", 16) header_stack.add_child(header_row) var progress_group := VBoxContainer.new() progress_group.add_theme_constant_override("separation", 2) progress_group.size_flags_vertical = Control.SIZE_SHRINK_CENTER header_row.add_child(progress_group) progress_button = Button.new() progress_button.flat = true progress_button.alignment = HORIZONTAL_ALIGNMENT_LEFT progress_button.custom_minimum_size = Vector2(126, 20) progress_button.tooltip_text = "Open question navigator" _set_scaled_button_font(progress_button, 14) progress_button.pressed.connect(_toggle_question_navigator) progress_group.add_child(progress_button) var progress_bar_row := HBoxContainer.new() progress_bar_row.add_theme_constant_override("separation", 8) progress_group.add_child(progress_bar_row) progress_bar = ProgressBar.new() progress_bar.show_percentage = false progress_bar.min_value = 0.0 progress_bar.max_value = 100.0 progress_bar.custom_minimum_size = Vector2(120, 6) progress_bar.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN progress_bar_row.add_child(progress_bar) progress_percent_label = Label.new() _set_scaled_label(progress_percent_label, 13, Brand.SLATE) progress_bar_row.add_child(progress_percent_label) timer_label = Label.new() _set_scaled_label(timer_label, 14, Brand.SLATE) timer_label.visible = false timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT question_toolbar = HFlowContainer.new() question_toolbar.add_theme_constant_override("h_separation", 8) question_toolbar.add_theme_constant_override("v_separation", 8) question_toolbar.alignment = FlowContainer.ALIGNMENT_END # All the underlying buttons are constructed up front (same as before) so every # other place in this file that mutates them by reference - theming, visibility, # tooltips, badge state - keeps working unmodified. Only *where* each one is # parented (directly in question_toolbar vs. reparented into a popup) changes. theme_button = Button.new() theme_button.custom_minimum_size = Vector2(44, 44) theme_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER theme_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER theme_button.alignment = HORIZONTAL_ALIGNMENT_CENTER theme_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER theme_button.focus_mode = Control.FOCUS_NONE UISoundService.set_button_sound(theme_button, "toggle") theme_button.pressed.connect(theme_service.toggle_theme) _refresh_theme_button() text_smaller_button = Button.new() text_smaller_button.text = "A-" text_smaller_button.tooltip_text = "Decrease text size" text_smaller_button.custom_minimum_size = Vector2(44, 44) Style.style_chip(text_smaller_button, Style.CYAN) UISoundService.set_button_sound(text_smaller_button, "toggle") text_smaller_button.pressed.connect(func(): _adjust_question_text_scale(-TEXT_SCALE_STEP)) text_larger_button = Button.new() text_larger_button.text = "A+" text_larger_button.tooltip_text = "Increase text size" text_larger_button.custom_minimum_size = Vector2(44, 44) Style.style_chip(text_larger_button, Style.CYAN) UISoundService.set_button_sound(text_larger_button, "toggle") text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP)) exit_button = Button.new() exit_button.name = "ExitButton" exit_button.tooltip_text = "Return to main area" exit_button.icon = preload("res://assets/icons/lucide/house.svg") exit_button.custom_minimum_size = Vector2(44, 44) exit_button.add_theme_constant_override("icon_max_width", 22) exit_button.alignment = HORIZONTAL_ALIGNMENT_CENTER exit_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER exit_button.focus_mode = Control.FOCUS_NONE UISoundService.set_button_sound(exit_button, "back") exit_button.pressed.connect(func(): main.exit_current_run()) calculator_button = Button.new() calculator_button.name = "CalculatorButton" calculator_button.tooltip_text = "Open calculator" calculator_button.icon = preload("res://assets/icons/lucide/calculator.svg") calculator_button.custom_minimum_size = Vector2(44, 44) calculator_button.add_theme_constant_override("icon_max_width", 22) calculator_button.alignment = HORIZONTAL_ALIGNMENT_CENTER calculator_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER calculator_button.focus_mode = Control.FOCUS_NONE UISoundService.set_button_sound(calculator_button, "open") calculator_button.pressed.connect(_toggle_calculator) labs_button = Button.new() labs_button.tooltip_text = "Review lab results" labs_button.custom_minimum_size = Vector2(44, 44) UISoundService.set_button_sound(labs_button, "open") labs_button.pressed.connect(_show_labs_modal) clear_highlight_button = Button.new() clear_highlight_button.tooltip_text = "Clear highlights" clear_highlight_button.custom_minimum_size = Vector2(44, 44) UISoundService.set_button_sound(clear_highlight_button, "eliminate") clear_highlight_button.pressed.connect(_clear_current_highlights) flag_button = Button.new() flag_button.toggle_mode = true flag_button.tooltip_text = "Flag question" flag_button.custom_minimum_size = Vector2(44, 44) Style.style_chip(flag_button, Style.AMBER) UISoundService.set_button_sound(flag_button, "toggle") flag_button.toggled.connect(func(value: bool): flagged = value _refresh_toolbar_state() if BlockSessionService.session_kind == "simulation": _save_simulation_current_response() ) flashcard_button = Button.new() flashcard_button.tooltip_text = "Save as flashcard (available once answered)" flashcard_button.icon = preload("res://assets/icons/lucide/clipboard-plus.svg") flashcard_button.custom_minimum_size = Vector2(44, 44) flashcard_button.add_theme_constant_override("icon_max_width", 22) flashcard_button.alignment = HORIZONTAL_ALIGNMENT_CENTER flashcard_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER flashcard_button.focus_mode = Control.FOCUS_NONE flashcard_button.disabled = true UISoundService.set_button_sound(flashcard_button, "toggle") flashcard_button.pressed.connect(_toggle_flashcard_current_question) flashcard_prefill_dialog = FlashcardPrefillDialog.new() flashcard_prefill_dialog.name = "FlashcardPrefillDialog" add_child(flashcard_prefill_dialog) bookmark_button = Button.new() bookmark_button.tooltip_text = "Save question" bookmark_button.icon = preload("res://assets/icons/lucide/bookmark.svg") bookmark_button.custom_minimum_size = Vector2(44, 44) bookmark_button.add_theme_constant_override("icon_max_width", 22) bookmark_button.alignment = HORIZONTAL_ALIGNMENT_CENTER bookmark_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER bookmark_button.focus_mode = Control.FOCUS_NONE UISoundService.set_button_sound(bookmark_button, "toggle") bookmark_button.pressed.connect(_toggle_bookmark_current_question) report_button = Button.new() report_button.tooltip_text = "Report question" report_button.custom_minimum_size = Vector2(44, 44) UISoundService.set_button_sound(report_button, "open") report_button.pressed.connect(_toggle_report_current_question) notes_button = Button.new() notes_button.tooltip_text = "Open notes" notes_button.icon = preload("res://assets/icons/lucide/notebook-pen.svg") notes_button.custom_minimum_size = Vector2(44, 44) notes_button.add_theme_constant_override("icon_max_width", 22) notes_button.alignment = HORIZONTAL_ALIGNMENT_CENTER notes_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER notes_button.focus_mode = Control.FOCUS_NONE UISoundService.set_button_sound(notes_button, "open") notes_button.pressed.connect(func(): main.open_notes_dialog()) app_feedback_button = Button.new() app_feedback_button.text = "Feedback" app_feedback_button.tooltip_text = "Send general feedback or report a bug" app_feedback_button.custom_minimum_size = Vector2(92, 44) Style.style_chip(app_feedback_button, Style.CYAN) app_feedback_button.pressed.connect(func(): main.open_feedback_dialog()) credits_badge = CreditsBadge.new() # Every action is directly visible - no "More" overflow or popups hiding # controls behind an extra click. No text labels either (their varying widths # broke even spacing when a button hid itself) - each button's existing # tooltip_text carries the label as hover text instead. question_toolbar.add_child(exit_button) question_toolbar.add_child(text_smaller_button) question_toolbar.add_child(text_larger_button) question_toolbar.add_child(calculator_button) question_toolbar.add_child(labs_button) question_toolbar.add_child(clear_highlight_button) question_toolbar.add_child(flag_button) question_toolbar.add_child(flashcard_button) question_toolbar.add_child(bookmark_button) question_toolbar.add_child(report_button) question_toolbar.add_child(notes_button) question_toolbar.add_child(theme_button) question_toolbar.add_child(app_feedback_button) question_toolbar.add_child(credits_badge) header_toolbar_host = HBoxContainer.new() header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END header_toolbar_host.add_child(question_toolbar) header_brand.add_child(header_toolbar_host) navigator_popup = PopupPanel.new() navigator_popup.name = "QuestionNavigator" navigator_popup.min_size = Vector2i(340, 180) add_child(navigator_popup) var navigator_margin := MarginContainer.new() navigator_margin.add_theme_constant_override("margin_left", 14) navigator_margin.add_theme_constant_override("margin_right", 14) navigator_margin.add_theme_constant_override("margin_top", 14) navigator_margin.add_theme_constant_override("margin_bottom", 14) navigator_popup.add_child(navigator_margin) var navigator_content := VBoxContainer.new() navigator_content.add_theme_constant_override("separation", 10) navigator_margin.add_child(navigator_content) var navigator_heading := Label.new() navigator_heading.name = "NavigatorHeading" navigator_heading.text = "Questions" _set_scaled_label(navigator_heading, 16, Brand.DARK_TEXT) navigator_content.add_child(navigator_heading) var navigator_scroll := ScrollContainer.new() navigator_scroll.custom_minimum_size = Vector2(310, 120) navigator_content.add_child(navigator_scroll) nav_palette = GridContainer.new() nav_palette.columns = 8 nav_palette.add_theme_constant_override("h_separation", 6) nav_palette.add_theme_constant_override("v_separation", 6) navigator_scroll.add_child(nav_palette) question_panel = PanelContainer.new() question_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL card_content.add_child(question_panel) question_scroll = ScrollContainer.new() question_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL question_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED question_panel.add_child(question_scroll) var content_margin := MarginContainer.new() content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL content_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL content_margin.add_theme_constant_override("margin_left", 4) content_margin.add_theme_constant_override("margin_right", 12) content_margin.add_theme_constant_override("margin_top", 20) question_scroll.add_child(content_margin) question_content = VBoxContainer.new() question_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL question_content.size_flags_vertical = Control.SIZE_EXPAND_FILL question_content.add_theme_constant_override("separation", 12) content_margin.add_child(question_content) category_label = Label.new() category_label.visible = false _set_scaled_label(category_label, 14, Brand.SKY_BLUE) question_content.add_child(category_label) stem_label = RichTextLabel.new() stem_label.fit_content = true stem_label.bbcode_enabled = true stem_label.selection_enabled = true stem_label.scroll_active = false stem_label.custom_minimum_size = Vector2(0, 96) _set_scaled_rich_text(stem_label, 18) question_content.add_child(stem_label) media_container = VBoxContainer.new() media_container.visible = false media_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL media_container.add_theme_constant_override("separation", 10) question_content.add_child(media_container) var lead_margin := MarginContainer.new() lead_margin.add_theme_constant_override("margin_top", 12) lead_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL question_content.add_child(lead_margin) lead_label = Label.new() lead_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(lead_label, 21, Style.TEXT) lead_margin.add_child(lead_label) question_elo_label = Label.new() question_elo_label.visible = false question_elo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _set_scaled_label(question_elo_label, 16, Style.AMBER) question_content.add_child(question_elo_label) var choices_container := VBoxContainer.new() choices_container.add_theme_constant_override("separation", 10) question_content.add_child(choices_container) for index in range(5): var choice_row := VBoxContainer.new() choice_row.add_theme_constant_override("separation", 0) choices_container.add_child(choice_row) choice_rows.append(choice_row) var button := Button.new() button.custom_minimum_size = Vector2(0, 68) button.alignment = HORIZONTAL_ALIGNMENT_LEFT Style.style_answer_button(button) UISoundService.set_button_sound(button, "select") _set_scaled_button_font(button, 17) button.pressed.connect(func(i := index): _select_choice(i)) button.gui_input.connect(func(event: InputEvent, i := index): _choice_gui_input(event, i)) choice_buttons.append(button) var badge_wrap := Control.new() badge_wrap.mouse_filter = Control.MOUSE_FILTER_IGNORE badge_wrap.anchor_left = 0.0 badge_wrap.anchor_top = 0.0 badge_wrap.anchor_right = 0.0 badge_wrap.anchor_bottom = 0.0 badge_wrap.offset_left = 16 badge_wrap.offset_top = 17 badge_wrap.offset_right = 50 badge_wrap.offset_bottom = 51 button.add_child(badge_wrap) # The badge circle is a pre-rendered SVG (tinted per-state via modulate) rather than a # StyleBoxFlat corner-radius circle - a perfectly circular StyleBoxFlat is a known Godot # degenerate case that can show a seam/faceting at this size even with AA tuned. var badge_texture := TextureRect.new() badge_texture.mouse_filter = Control.MOUSE_FILTER_IGNORE badge_texture.texture = BADGE_CIRCLE_OUTLINE_ICON badge_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED badge_texture.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) badge_wrap.add_child(badge_texture) choice_badge_textures.append(badge_texture) var badge := Label.new() badge.mouse_filter = Control.MOUSE_FILTER_IGNORE badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER badge.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _set_scaled_label(badge, 15, Brand.muted_color(true)) badge_wrap.add_child(badge) choice_badges.append(badge) var percent_label := Label.new() percent_label.visible = false percent_label.mouse_filter = Control.MOUSE_FILTER_IGNORE percent_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT percent_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER percent_label.anchor_left = 1.0 percent_label.anchor_top = 0.0 percent_label.anchor_right = 1.0 percent_label.anchor_bottom = 0.0 percent_label.offset_left = -82 percent_label.offset_top = 18 percent_label.offset_right = -18 percent_label.offset_bottom = 50 _set_scaled_label(percent_label, 18, Style.TEXT) button.add_child(percent_label) choice_percent_labels.append(percent_label) var selected_icon := TextureRect.new() selected_icon.visible = false selected_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE selected_icon.texture = preload("res://assets/icons/lucide/circle-check.svg") selected_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED selected_icon.modulate = Brand.PURPLE selected_icon.anchor_left = 1.0 selected_icon.anchor_top = 0.0 selected_icon.anchor_right = 1.0 selected_icon.anchor_bottom = 0.0 selected_icon.offset_left = -50 selected_icon.offset_top = 18 selected_icon.offset_right = -18 selected_icon.offset_bottom = 50 button.add_child(selected_icon) choice_selected_icons.append(selected_icon) var top_label := RichTextLabel.new() top_label.visible = false top_label.mouse_filter = Control.MOUSE_FILTER_IGNORE top_label.bbcode_enabled = true top_label.fit_content = false top_label.scroll_active = false top_label.anchor_left = 0.0 top_label.anchor_top = 0.0 top_label.anchor_right = 1.0 top_label.anchor_bottom = 0.0 top_label.offset_left = 62 top_label.offset_top = 18 top_label.offset_right = -96 top_label.offset_bottom = 50 _set_scaled_rich_text(top_label, 16) top_label.add_theme_color_override("default_color", Style.RED) button.add_child(top_label) choice_top_labels.append(top_label) choice_row.add_child(button) var followup_panel := PanelContainer.new() followup_panel.visible = false followup_panel.mouse_filter = Control.MOUSE_FILTER_STOP choice_row.add_child(followup_panel) choice_followup_panels.append(followup_panel) var followup_content := VBoxContainer.new() followup_content.add_theme_constant_override("separation", 8) followup_panel.add_child(followup_content) choice_followup_contents.append(followup_content) _build_answer_review_ui() actions = HBoxContainer.new() actions.add_theme_constant_override("separation", 16) card_content.add_child(actions) skip_button = Button.new() skip_button.text = "Skip" skip_button.flat = true skip_button.custom_minimum_size = Vector2(0, 40) UISoundService.set_button_sound(skip_button, "back") skip_button.pressed.connect(_skip_current_question) actions.add_child(skip_button) var bottom_progress_group := VBoxContainer.new() bottom_progress_group.add_theme_constant_override("separation", 4) bottom_progress_group.size_flags_horizontal = Control.SIZE_EXPAND_FILL bottom_progress_group.alignment = BoxContainer.ALIGNMENT_CENTER actions.add_child(bottom_progress_group) bottom_progress_label = Label.new() bottom_progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _set_scaled_label(bottom_progress_label, 13, Brand.SLATE) bottom_progress_group.add_child(bottom_progress_label) bottom_progress_bar = ProgressBar.new() bottom_progress_bar.show_percentage = false bottom_progress_bar.min_value = 0.0 bottom_progress_bar.max_value = 100.0 bottom_progress_bar.custom_minimum_size = Vector2(160, 6) bottom_progress_bar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER bottom_progress_group.add_child(bottom_progress_bar) # The timer readout still runs (BlockSessionService pacing/scoring depends on # it, and _update_timer_bar keeps its text current), it's just no longer # parented into the bottom bar - the caption + digits and the "Ctrl+Enter" # hint were clutter next to the Submit button. timer_caption_label = Label.new() timer_caption_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _set_scaled_label(timer_caption_label, 12, Brand.SLATE) next_button = Button.new() next_button.text = "Submit Block" if BlockSessionService.session_kind == "simulation" and BlockSessionService.current_index + 1 == BlockSessionService.active_questions.size() else "Next" if BlockSessionService.session_kind == "simulation" else "Submit" next_button.tooltip_text = "" next_button.custom_minimum_size = Vector2(140, 48) Style.style_primary_button(next_button) next_button.pressed.connect(_submit_or_next) actions.add_child(next_button) timer_footer = HBoxContainer.new() timer_footer.add_theme_constant_override("separation", 12) card_content.add_child(timer_footer) timer_bar = ProgressBar.new() timer_bar.show_percentage = false timer_bar.min_value = 0.0 timer_bar.max_value = 100.0 timer_bar.value = 100.0 timer_bar.custom_minimum_size = Vector2(0, 7) timer_bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL timer_bar.add_theme_stylebox_override("background", Style.button_box(Color("232b3d"), Color("303b53"))) timer_bar.add_theme_stylebox_override("fill", Style.button_box(Style.LIME, Style.LIME)) timer_footer.add_child(timer_bar) feedback_fog = ColorRect.new() feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0) feedback_fog.mouse_filter = Control.MOUSE_FILTER_IGNORE feedback_fog.top_level = true feedback_fog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(feedback_fog) _build_labs_modal() calculator_panel = CalculatorPanel.new() add_child(calculator_panel) _build_report_modal() _build_media_viewer() resized.connect(_fit_modal_panels) resized.connect(_refresh_responsive_layout) _fit_modal_panels() _refresh_responsive_layout() _apply_question_theme() func _build_answer_review_ui() -> void: correct_review_panel = PanelContainer.new() correct_review_panel.visible = false question_content.add_child(correct_review_panel) var review_margin := MarginContainer.new() review_margin.add_theme_constant_override("margin_left", 14) review_margin.add_theme_constant_override("margin_right", 14) review_margin.add_theme_constant_override("margin_top", 12) review_margin.add_theme_constant_override("margin_bottom", 12) correct_review_panel.add_child(review_margin) var review_content := VBoxContainer.new() review_content.add_theme_constant_override("separation", 10) review_margin.add_child(review_content) explanation_label = RichTextLabel.new() explanation_label.bbcode_enabled = true explanation_label.fit_content = true explanation_label.selection_enabled = true explanation_label.scroll_active = false explanation_label.custom_minimum_size = Vector2.ZERO _set_scaled_rich_text(explanation_label, 17) review_content.add_child(explanation_label) teaching_cards = HFlowContainer.new() teaching_cards.add_theme_constant_override("h_separation", 10) teaching_cards.add_theme_constant_override("v_separation", 10) review_content.add_child(teaching_cards) references_container = VBoxContainer.new() references_container.visible = false references_container.add_theme_constant_override("separation", 6) review_content.add_child(references_container) review_followups_section = VBoxContainer.new() review_followups_section.visible = false review_followups_section.add_theme_constant_override("separation", 8) question_content.add_child(review_followups_section) var followup_heading := Label.new() followup_heading.text = "FOLLOW-UP QUESTIONS" followup_heading.name = "ReviewFollowupHeading" _set_scaled_label(followup_heading, 15, Brand.PURPLE) review_followups_section.add_child(followup_heading) var followup_subtitle := Label.new() followup_subtitle.text = "Answer to reinforce key concepts. These responses do not affect your score." followup_subtitle.name = "ReviewFollowupSubtitle" followup_subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(followup_subtitle, 14, Brand.SLATE) review_followups_section.add_child(followup_subtitle) review_followups_content = VBoxContainer.new() review_followups_content.add_theme_constant_override("separation", 8) review_followups_section.add_child(review_followups_content) func _fit_modal_panels() -> void: var available := get_viewport_rect().size - Vector2(32, 32) if report_panel != null: report_panel.custom_minimum_size = Vector2( minf(700.0, maxf(320.0, available.x)), minf(600.0, maxf(560.0, available.y))) if labs_panel != null: labs_panel.custom_minimum_size = Vector2( minf(760.0, maxf(320.0, available.x)), minf(430.0, maxf(360.0, available.y))) func _refresh_responsive_layout() -> void: if header_toolbar_host == null or header_brand == null or header_stack == null: return var compact := size.x < 1120.0 var target_parent: Control = header_brand if compact: target_parent = header_stack if header_toolbar_host.get_parent() != target_parent: header_toolbar_host.reparent(target_parent) header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL # 12 bare 44px icon buttons + the wider 92px Feedback button + the ~194px # rank/ELO + points badge, with 8px gaps between all 14 - anything less # than this wraps onto a second row. header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 960.0 if size.x < 560.0: nav_palette.columns = 5 else: nav_palette.columns = 8 func _question_card_box(dark: bool) -> StyleBoxFlat: var box := StyleBoxFlat.new() box.bg_color = Color("081326") if dark else Color("ffffff") box.border_color = Color("253555") if dark else Brand.LIGHT_BORDER box.set_border_width_all(1) box.set_corner_radius_all(8) return box func _question_content_box(dark: bool) -> StyleBoxFlat: var box := StyleBoxFlat.new() box.bg_color = Color("081326") if dark else Color("ffffff") box.set_corner_radius_all(6) return box func _answer_box(dark: bool, state: String, attached: bool = false) -> StyleBoxFlat: var box := StyleBoxFlat.new() box.content_margin_left = 62 box.content_margin_right = 82 box.content_margin_top = 22 box.content_margin_bottom = 22 if state == "default" or state == "eliminated": # Every choice reads as its own subtly-bordered card, even before answering - # only selected/correct/wrong states get a stronger accent border/background. box.bg_color = Color("0b172b") if dark else Color("ffffff") box.border_color = Color("22304d") if dark else Color("e7ebf2") box.set_border_width_all(1) box.set_corner_radius_all(14) return box var background := Color("17183b") if dark else Color("eff4ff") var border := Brand.PURPLE if state == "correct": background = Color("08251d") if dark else Color("ecfdf3") border = Brand.SUCCESS elif state == "wrong": background = Color("32131d") if dark else Color("fff1f2") border = Color("fb7185") box.bg_color = background box.border_color = border box.set_border_width_all(2) box.set_corner_radius_all(14) if attached: # A review/follow-up panel is docked directly beneath this choice - drop the # shared edge so the two read as one continuous card instead of two stacked boxes. box.border_width_bottom = 0 box.corner_radius_bottom_left = 0 box.corner_radius_bottom_right = 0 return box func _attached_panel_box(dark: bool, accent: Color) -> StyleBoxFlat: var box := StyleBoxFlat.new() box.bg_color = Color("17183b") if dark else Color("eff4ff") if accent == Brand.SUCCESS: box.bg_color = Color("08251d") if dark else Color("ecfdf3") elif accent == Color("fb7185"): box.bg_color = Color("32131d") if dark else Color("fff1f2") box.border_color = accent box.border_width_left = 2 box.border_width_right = 2 box.border_width_bottom = 2 box.border_width_top = 0 box.corner_radius_top_left = 0 box.corner_radius_top_right = 0 box.corner_radius_bottom_left = 14 box.corner_radius_bottom_right = 14 box.content_margin_left = 12 box.content_margin_right = 12 box.content_margin_top = 10 box.content_margin_bottom = 10 return box func _correct_review_panel_box(dark: bool) -> StyleBoxFlat: # correct_review_panel already pads its content via an inner MarginContainer, # so skip the shared attached-panel content margin to avoid doubling it up. var box := _attached_panel_box(dark, Brand.SUCCESS) box.content_margin_left = 0 box.content_margin_right = 0 box.content_margin_top = 0 box.content_margin_bottom = 0 return box func _toolbar_box(dark: bool, active: bool = false) -> StyleBoxFlat: var box := StyleBoxFlat.new() box.bg_color = Color("13203a") if dark else Color("f8fafc") box.border_color = Brand.PURPLE if active else (Color("33415d") if dark else Brand.LIGHT_BORDER) box.set_border_width_all(1) box.set_corner_radius_all(7) box.content_margin_left = 0 box.content_margin_right = 0 box.content_margin_top = 0 box.content_margin_bottom = 0 return box func _style_theme_toggle_button(dark: bool) -> void: if theme_button == null: return Brand.set_theme_toggle_icon(theme_button, dark) theme_button.tooltip_text = "Switch to light mode" if dark else "Switch to dark mode" # Match the same rounded-square box every other toolbar icon uses, rather than # the distinct circular pill shape - it should read as one of the toolbar, not # a visually separate control. theme_button.add_theme_stylebox_override("normal", _toolbar_box(dark)) theme_button.add_theme_stylebox_override("hover", _toolbar_box(dark, true)) theme_button.add_theme_stylebox_override("pressed", _toolbar_box(dark, true)) theme_button.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) theme_button.add_theme_constant_override("h_separation", 0) func _style_asset_toolbar_button(button: Button, texture: Texture2D, dark: bool, active: bool = false) -> void: if button == null: return button.text = "" button.icon = texture button.expand_icon = false button.focus_mode = Control.FOCUS_NONE button.alignment = HORIZONTAL_ALIGNMENT_CENTER button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER button.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER button.size_flags_vertical = Control.SIZE_SHRINK_CENTER button.add_theme_constant_override("h_separation", 0) button.add_theme_constant_override("icon_max_width", 23) button.add_theme_stylebox_override("normal", _toolbar_box(dark, active)) button.add_theme_stylebox_override("hover", _toolbar_box(dark, true)) button.add_theme_stylebox_override("pressed", _toolbar_box(dark, true)) button.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) for color_name in ["icon_normal_color", "icon_hover_color", "icon_pressed_color", "icon_focus_color"]: button.add_theme_color_override(color_name, Color.WHITE) func _primary_action_box(dark: bool, state: String) -> StyleBoxFlat: var box := StyleBoxFlat.new() if state == "disabled": box.bg_color = Color("1b2539") if dark else Color("dbe3ef") box.border_color = Color("2b3952") if dark else Color("cbd5e1") elif state == "hover": box.bg_color = Brand.PURPLE box.border_color = Color("a78bfa") elif state == "pressed": box.bg_color = Color("6d28d9") box.border_color = Brand.PURPLE else: box.bg_color = Color("4f5ff7") box.border_color = Brand.PURPLE box.set_border_width_all(1) box.set_corner_radius_all(7) box.content_margin_left = 14 box.content_margin_right = 14 box.content_margin_top = 10 box.content_margin_bottom = 10 return box func _teaching_card_box(dark: bool, accent: Color) -> StyleBoxFlat: var box := StyleBoxFlat.new() box.bg_color = Color(accent, 0.08 if dark else 0.045) box.border_color = Color(accent, 0.42) box.set_border_width_all(1) box.set_corner_radius_all(6) box.content_margin_left = 12 box.content_margin_right = 12 box.content_margin_top = 10 box.content_margin_bottom = 10 return box func _review_followup_box(_dark: bool, accent: Color, answered: bool) -> StyleBoxFlat: var box := StyleBoxFlat.new() box.bg_color = Color(accent, 0.075 if answered else 0.04) box.border_color = Color(accent, 0.5) box.set_border_width_all(1) box.set_corner_radius_all(7) box.content_margin_left = 12 box.content_margin_right = 12 box.content_margin_top = 10 box.content_margin_bottom = 10 return box func _style_review_followup_option(button: Button, dark: bool, option_id: String, selected_id: String, correct_id: String, answered: bool) -> void: var state := "default" if answered and option_id == correct_id: state = "correct" elif answered and option_id == selected_id: state = "wrong" var box := _answer_box(dark, state) box.content_margin_left = 12 box.content_margin_right = 12 var hover_box := _answer_box(dark, "selected" if state == "default" else state) hover_box.content_margin_left = 12 hover_box.content_margin_right = 12 button.add_theme_stylebox_override("normal", box) button.add_theme_stylebox_override("disabled", box) button.add_theme_stylebox_override("hover", hover_box) button.add_theme_stylebox_override("pressed", hover_box) button.add_theme_color_override("font_color", Brand.text_color(dark)) button.add_theme_color_override("font_disabled_color", Brand.text_color(dark)) func _apply_question_theme() -> void: if theme_service == null or question_card == null or question_panel == null: return var dark: bool = theme_service.is_dark_mode() question_card.add_theme_stylebox_override("panel", _question_card_box(dark)) question_panel.add_theme_stylebox_override("panel", _question_content_box(dark)) if navigator_popup != null: navigator_popup.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) progress_button.add_theme_color_override("font_color", Brand.text_color(dark)) progress_button.add_theme_color_override("font_hover_color", Brand.SKY_BLUE) progress_button.add_theme_color_override("font_disabled_color", Brand.text_color(dark)) if header_wordmark_label != null: header_wordmark_label.add_theme_color_override("font_color", Brand.text_color(dark)) var progress_track := Style.button_box(Color("232b3d") if dark else Color("e2e8f0"), Color("303b53") if dark else Color("cbd5e1")) var progress_fill := Style.button_box(Brand.ROYAL_BLUE, Brand.ROYAL_BLUE) if progress_bar != null: progress_bar.add_theme_stylebox_override("background", progress_track) progress_bar.add_theme_stylebox_override("fill", progress_fill) if progress_percent_label != null: progress_percent_label.add_theme_color_override("font_color", Brand.muted_color(dark)) if bottom_progress_bar != null: bottom_progress_bar.add_theme_stylebox_override("background", progress_track) bottom_progress_bar.add_theme_stylebox_override("fill", progress_fill) if bottom_progress_label != null: bottom_progress_label.add_theme_color_override("font_color", Brand.muted_color(dark)) if category_label != null: category_label.add_theme_color_override("font_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE) timer_label.add_theme_color_override("font_color", Brand.muted_color(dark)) if timer_caption_label != null: timer_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark)) stem_label.add_theme_color_override("default_color", Brand.text_color(dark)) lead_label.add_theme_color_override("font_color", Brand.text_color(dark)) explanation_label.add_theme_color_override("default_color", Brand.text_color(dark)) var review_followup_heading := find_child("ReviewFollowupHeading", true, false) as Label if review_followup_heading != null: review_followup_heading.add_theme_color_override("font_color", Brand.PURPLE if not dark else Color("8b7cff")) var review_followup_subtitle := find_child("ReviewFollowupSubtitle", true, false) as Label if review_followup_subtitle != null: review_followup_subtitle.add_theme_color_override("font_color", Brand.muted_color(dark)) if correct_review_panel != null: correct_review_panel.add_theme_stylebox_override("panel", _correct_review_panel_box(dark)) question_elo_label.add_theme_color_override("font_color", Brand.AMBER) var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label if navigator_heading != null: navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark)) for control in [exit_button, text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]: if control == null: continue var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", ""))) control.add_theme_stylebox_override("normal", _toolbar_box(dark, active)) control.add_theme_stylebox_override("hover", _toolbar_box(dark, true)) control.add_theme_stylebox_override("pressed", _toolbar_box(dark, true)) control.add_theme_color_override("font_color", Brand.text_color(dark)) control.add_theme_color_override("icon_normal_color", Brand.SKY_BLUE) _style_asset_toolbar_button(labs_button, LABS_DARK_ICON if dark else LABS_LIGHT_ICON, dark) _style_asset_toolbar_button(clear_highlight_button, CLEAR_HIGHLIGHT_DARK_ICON if dark else CLEAR_HIGHLIGHT_LIGHT_ICON, dark) _style_asset_toolbar_button(report_button, REPORT_QUESTION_DARK_ICON if dark else REPORT_QUESTION_LIGHT_ICON, dark) _style_asset_toolbar_button(flag_button, FLAG_DARK_ICON if dark else FLAG_LIGHT_ICON, dark, flagged) _style_theme_toggle_button(dark) if calculator_panel != null: calculator_panel.refresh_theme_controls() if credits_badge != null: credits_badge.refresh_theme_controls() if skip_button != null: var skip_link_color := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE skip_button.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) skip_button.add_theme_stylebox_override("hover", StyleBoxEmpty.new()) skip_button.add_theme_stylebox_override("pressed", StyleBoxEmpty.new()) skip_button.add_theme_stylebox_override("disabled", StyleBoxEmpty.new()) skip_button.add_theme_color_override("font_color", skip_link_color) skip_button.add_theme_color_override("font_hover_color", skip_link_color) skip_button.add_theme_color_override("font_disabled_color", Brand.muted_color(dark)) if next_button != null: next_button.add_theme_stylebox_override("normal", _primary_action_box(dark, "normal")) next_button.add_theme_stylebox_override("hover", _primary_action_box(dark, "hover")) next_button.add_theme_stylebox_override("pressed", _primary_action_box(dark, "pressed")) next_button.add_theme_stylebox_override("disabled", _primary_action_box(dark, "disabled")) next_button.add_theme_color_override("font_color", Color.WHITE) next_button.add_theme_color_override("font_hover_color", Color.WHITE) next_button.add_theme_color_override("font_pressed_color", Color.WHITE) next_button.add_theme_color_override("font_disabled_color", Brand.muted_color(dark)) for index in range(choice_buttons.size()): var choice_id := "" if index < presented_choices.size(): choice_id = str(presented_choices[index].get("id", "")) _style_choice_button_for_state(index, choice_id) for attempted_choice_id in followup_attempt_order: _render_followup_state(str(attempted_choice_id)) # Guarding on current_answer/pending_correct_advance too (not just the panel's # own visible flag) so these can never render for a question that hasn't # actually been answered yet, even if visible was left stale by some other path. if correct_review_panel != null and correct_review_panel.visible and (not current_answer.is_empty() or pending_correct_advance): _render_teaching_cards() _render_review_followups() _render_unselected_distractor_explanations() func on_show(_payload: Variant = null) -> void: _load_question() func on_hide() -> void: if BlockSessionService.session_kind == "simulation" and BlockSessionService.block_started: _save_simulation_current_response() awaiting_next_question = false _hide_media_viewer() _hide_report_modal(true) _hide_labs_modal() if calculator_panel != null: calculator_panel.close_panel() calculator_panel.reset_calculation() func _save_simulation_current_response() -> void: if BlockSessionService.session_kind != "simulation": return var question := BlockSessionService.get_current_question() if question.is_empty(): return current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged) ExamSimulationService.save_response( str(question.get("id", "")), selected_choice, eliminated, flagged, int(BlockSessionService.get_current_elapsed_seconds())) func _on_simulation_state_changed() -> void: if BlockSessionService.session_kind != "simulation": return var state := str(ExamSimulationService.simulation.get("state", "")) if state == "between_blocks": simulation_submit_pending = false BlockSessionService.block_started = false main.show_screen("simulation_break") elif state == "completed": simulation_submit_pending = false BlockSessionService.block_started = false main.show_screen("simulation_results", ExamSimulationService.simulation) func _on_simulation_request_failed(message: String) -> void: if BlockSessionService.session_kind != "simulation": return simulation_submit_pending = false next_button.disabled = false next_button.text = "Retry" next_button.tooltip_text = message func _process(delta: float) -> void: _update_answer_feedback(delta) if visible and BlockSessionService.session_kind == "simulation": BlockSessionService.seconds_remaining = maxf(0.0, ExamSimulationService.get_block_deadline_unix() - Time.get_unix_time_from_system()) var simulation_seconds := int(ceil(BlockSessionService.seconds_remaining)) _set_timer_display(simulation_seconds) _update_timer_bar() if simulation_seconds <= 0 and not simulation_submit_pending: simulation_submit_pending = true ExamSimulationService.submit_block() return if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core": _update_timer_bar() if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed" and (report_overlay == null or not report_overlay.visible): BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta) var shown_seconds := int(ceil(BlockSessionService.seconds_remaining)) _set_timer_display(shown_seconds) _update_timer_bar() if BlockSessionService.seconds_remaining <= 0: _finish() if visible and not _is_session_timer_visible() and (report_overlay == null or not report_overlay.visible): # No real time limit applies here - show a simple local count-up stopwatch # ("Time elapsed") so the bottom-bar time slot is never blank, without # touching BlockSessionService's own (block-scoped, not per-question) timing. var elapsed_seconds := int(floor(float(Time.get_ticks_msec() - untimed_elapsed_start_msec) / 1000.0)) _set_timer_display(maxi(0, elapsed_seconds)) func _input(event: InputEvent) -> void: if media_viewer_overlay != null and media_viewer_overlay.visible: if event.is_action_pressed("ui_cancel"): _hide_media_viewer() elif event.is_action_pressed("ui_left"): _show_adjacent_media(-1) elif event.is_action_pressed("ui_right"): _show_adjacent_media(1) elif event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_PLUS or event.keycode == KEY_EQUAL): _set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR) elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_MINUS: _set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR) else: return get_viewport().set_input_as_handled() return if report_overlay != null and report_overlay.visible and event.is_action_pressed("ui_cancel"): _hide_report_modal() get_viewport().set_input_as_handled() return if labs_overlay != null and labs_overlay.visible and event.is_action_pressed("ui_cancel"): _hide_labs_modal() get_viewport().set_input_as_handled() return if calculator_panel != null and calculator_panel.visible and event is InputEventKey: return if not visible or pending_correct_advance or stem_label == null: return if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ENTER and (event.ctrl_pressed or event.meta_pressed): var focus_owner := get_viewport().gui_get_focus_owner() var focus_blocks_shortcut := focus_owner is TextEdit or focus_owner is LineEdit var overlay_blocks_shortcut := (report_overlay != null and report_overlay.visible) or (labs_overlay != null and labs_overlay.visible) if not focus_blocks_shortcut and not overlay_blocks_shortcut and next_button != null and next_button.visible and not next_button.disabled: get_viewport().set_input_as_handled() _submit_or_next() return var mouse_event := event as InputEventMouseButton if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_LEFT: return if mouse_event.pressed: if stem_label.get_global_rect().has_point(mouse_event.global_position): highlight_drag_source = "stem" elif explanation_label.visible and explanation_label.get_global_rect().has_point(mouse_event.global_position): highlight_drag_source = "explanation" else: highlight_drag_source = "" elif highlight_drag_source != "": var selection_source := highlight_drag_source highlight_drag_source = "" call_deferred("_commit_current_text_selection", selection_source) elif _should_clear_selection_from_click(mouse_event): _clear_selected_choice() func _load_question() -> void: _hide_report_modal(true) _reset_wrong_feedback() _hide_labs_modal() eliminated.clear() locked_wrong_choice_ids.clear() _clear_followups() flagged = false selected_choice = "" current_answer = {} last_timer_bar_value = -1.0 last_timer_display_seconds = -1 flag_button.button_pressed = false _refresh_toolbar_state() _clear_answer_review() _clear_references() question_elo_label.visible = false question_elo_label.text = "" next_button.text = "Submit" next_button.disabled = true skip_button.disabled = false skip_button.visible = BlockSessionService.session_kind not in ["review", "dynamic"] skip_button.text = "Skip" report_button.visible = BlockSessionService.session_kind != "simulation" var question := BlockSessionService.get_current_question() if question.is_empty(): if BlockSessionService.session_kind == "review": main.show_screen("dashboard") elif BlockSessionService.session_kind == "dynamic": main.show_screen("dynamic_questions") else: main.show_screen("bookmarks" if BlockSessionService.session_kind == "bookmark" else "selection", "exam" if BlockSessionService.session_kind == "core" else "step3") return if BlockSessionService.session_kind != "dynamic": BookmarkService.observe_question(question) progress_button.text = _question_progress_text() progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"] progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"] else "Open question navigator" _update_progress_indicators() untimed_elapsed_start_msec = Time.get_ticks_msec() var show_timer := _is_session_timer_visible() # The time slot in the bottom bar is always visible - a countdown ("Time remaining") # when a time limit applies, otherwise a simple count-up stopwatch ("Time elapsed") # so the area is never blank for untimed practice. timer_label.visible = true timer_bar.visible = show_timer timer_footer.visible = show_timer if timer_caption_label != null: # "core" sessions show a per-question stopwatch that counts UP (no fixed # limit, just a soft pacing signal), while simulation/Timed sessions count # DOWN to a real deadline - the caption should match which one it is. if not show_timer or BlockSessionService.session_kind == "core": timer_caption_label.text = "Time elapsed" else: timer_caption_label.text = "Time remaining" if show_timer: if BlockSessionService.session_kind == "core": _set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))) else: _set_timer_display(int(ceil(BlockSessionService.seconds_remaining))) else: _set_timer_display(0) _update_timer_bar() stem_plain_text = str(question.get("stem", "")) explanation_plain_text = str(question.get("explanation", "")) _render_stem() _load_question_media(question) lead_label.text = question.get("lead_in", "") var category_text := str(question.get("discipline", "")).strip_edges() if category_text.is_empty(): category_text = str(question.get("system", "")).strip_edges() category_label.text = category_text category_label.visible = false _refresh_labs_button() presented_choices = question.get("presented_choices", question.get("choices", [])).duplicate(true) var choices := presented_choices for index in range(choice_buttons.size()): var button := choice_buttons[index] button.visible = index < choices.size() if index < choice_rows.size(): choice_rows[index].visible = index < choices.size() button.disabled = false if index < choices.size(): var choice: Dictionary = choices[index] _set_choice_button_text(index, choice) button.modulate = Color.WHITE _style_choice_button_for_state(index, str(choice.get("id", ""))) _refresh_submit_state() var saved_answer = BlockSessionService.get_answer_for_index(BlockSessionService.current_index) if saved_answer != null: current_answer = saved_answer selected_choice = str(saved_answer.get("selected_choice_id", "")) eliminated = saved_answer.get("eliminated_choice_ids", []).duplicate() flagged = bool(saved_answer.get("flagged", false)) flag_button.button_pressed = flagged _refresh_toolbar_state() if BlockSessionService.session_kind == "simulation": _apply_saved_choice_state() else: _show_answer_review() _update_question_elo_label(true) _refresh_nav_palette() _refresh_toolbar_state() _apply_question_theme() func _select_choice(index: int) -> void: if pending_correct_advance or followup_required or _is_correct_answer_locked(): return var choices := _get_presented_choices() if index >= choices.size() or (BlockSessionService.mode == "Tutor" and not current_answer.is_empty()): return if locked_wrong_choice_ids.has(str(choices[index].get("id", ""))): return var choice_id := str(choices[index].get("id", "")) selected_choice = "" if selected_choice == choice_id else choice_id if BlockSessionService.mode == "Timed" or BlockSessionService.mode == "Simulation": current_answer = {} if BlockSessionService.mode == "Simulation": _save_simulation_current_response() else: next_button.text = "Submit" _apply_saved_choice_state() func _clear_selected_choice() -> void: if selected_choice == "" or _is_correct_answer_locked(): return selected_choice = "" if BlockSessionService.mode == "Timed" or BlockSessionService.mode == "Simulation": current_answer = {} if BlockSessionService.mode == "Simulation": _save_simulation_current_response() else: next_button.text = "Submit" _apply_saved_choice_state() func _should_clear_selection_from_click(mouse_event: InputEventMouseButton) -> bool: if selected_choice == "" or followup_required or not current_answer.is_empty(): return false if mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT: return false var pointer_position := mouse_event.global_position if stem_label.get_global_rect().has_point(pointer_position): return false if lead_label.get_global_rect().has_point(pointer_position): return false if explanation_label.visible and explanation_label.get_global_rect().has_point(pointer_position): return false if labs_overlay != null and labs_overlay.visible: return false if skip_button != null and skip_button.get_global_rect().has_point(pointer_position): return false if next_button != null and next_button.get_global_rect().has_point(pointer_position): return false if question_toolbar != null and question_toolbar.get_global_rect().has_point(pointer_position): return false if labs_button != null and labs_button.visible and labs_button.get_global_rect().has_point(pointer_position): return false if flag_button != null and flag_button.visible and flag_button.get_global_rect().has_point(pointer_position): return false if report_button != null and report_button.visible and report_button.get_global_rect().has_point(pointer_position): return false for button in choice_buttons: if button.visible and button.get_global_rect().has_point(pointer_position): return false for panel in choice_followup_panels: if panel.visible and panel.get_global_rect().has_point(pointer_position): return false for child in media_container.get_children(): if child is Control and child.get_global_rect().has_point(pointer_position): return false return true func _skip_current_question() -> void: if BlockSessionService.session_kind == "bookmark": if not current_answer.is_empty(): _finish() return current_answer = BlockSessionService.skip_bookmark_question(eliminated, flagged) if current_answer.is_empty(): return selected_choice = "" _show_answer_review() return if pending_correct_advance or (not current_answer.is_empty() and BlockSessionService.session_kind != "simulation"): return if BlockSessionService.session_kind != "core": if BlockSessionService.session_kind == "simulation": selected_choice = "" current_answer = {} _save_simulation_current_response() var target_index := _next_pending_simulation_index() if BlockSessionService.session_kind == "simulation" else _next_revisitable_index() if target_index >= 0 and BlockSessionService.move_to(target_index): _load_question() else: _finish() return var question_id := str(BlockSessionService.get_current_question().get("id", "")) if question_id == "": return current_answer = BlockSessionService.skip_core_question(eliminated, flagged) if current_answer.is_empty(): return QuestionBankService.skip_question(question_id) selected_choice = "" next_button.disabled = true skip_button.disabled = true pending_correct_advance = true correct_advance_delay = CORRECT_ADVANCE_DELAY_SECONDS _show_explanation() _update_question_elo_label(true) func _next_revisitable_index() -> int: var question_count := BlockSessionService.active_questions.size() if question_count <= 1: return -1 for offset in range(1, question_count): var candidate := (BlockSessionService.current_index + offset) % question_count var answer = BlockSessionService.get_answer_for_index(candidate) if answer == null or str(answer.get("selected_choice_id", "")) == "": return candidate return (BlockSessionService.current_index + 1) % question_count func _next_pending_simulation_index() -> int: var question_count := BlockSessionService.active_questions.size() if question_count == 0: return -1 for offset in range(1, question_count + 1): var candidate := (BlockSessionService.current_index + offset) % question_count var answer = BlockSessionService.get_answer_for_index(candidate) if answer == null or str(answer.get("selected_choice_id", "")) == "": return candidate return -1 func _choice_gui_input(event: InputEvent, index: int) -> void: if pending_correct_advance or followup_required or _is_correct_answer_locked(): return var mouse_event := event as InputEventMouseButton if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed: return if not current_answer.is_empty() and BlockSessionService.session_kind != "simulation": return var choices := _get_presented_choices() if index >= choices.size(): return var choice: Dictionary = choices[index] var choice_id := str(choice.get("id", "")) if locked_wrong_choice_ids.has(choice_id): return if eliminated.has(choice_id): eliminated.erase(choice_id) else: eliminated.append(choice_id) UISoundService.play_sound("eliminate") _set_choice_button_text(index, choice) _apply_saved_choice_state() if BlockSessionService.session_kind == "simulation": _save_simulation_current_response() func _toggle_eliminate_selected() -> void: if pending_correct_advance or followup_required or selected_choice == "" or (not current_answer.is_empty() and BlockSessionService.session_kind != "simulation") or _is_correct_answer_locked(): return var choices := _get_presented_choices() for index in range(choices.size()): if str(choices[index].get("id", "")) == selected_choice: if eliminated.has(selected_choice): eliminated.erase(selected_choice) else: eliminated.append(selected_choice) UISoundService.play_sound("eliminate") _set_choice_button_text(index, choices[index]) _apply_saved_choice_state() if BlockSessionService.session_kind == "simulation": _save_simulation_current_response() func _submit_or_next() -> void: if BlockSessionService.session_kind == "simulation": if selected_choice == "": return _save_simulation_current_response() var pending_index := _next_pending_simulation_index() if pending_index >= 0: BlockSessionService.move_to(pending_index) _load_question() return if not simulation_submit_pending: simulation_submit_pending = true next_button.disabled = true next_button.text = "Submitting..." ExamSimulationService.submit_block() return if awaiting_next_question: return if pending_correct_advance: return if current_answer.is_empty(): if selected_choice == "": return var question_id_for_attempt := str(BlockSessionService.get_current_question().get("id", "")) if BlockSessionService.session_kind == "dynamic": var elapsed := int(floor(BlockSessionService.get_current_elapsed_seconds())) DynamicQuestionService.submit_attempt(selected_choice, elapsed) current_answer = BlockSessionService.answer_current(selected_choice, eliminated, false) _show_answer_review() return if BlockSessionService.session_kind in ["core", "bookmark", "review"]: var attempted_choice := selected_choice var met_time_limit := true if BlockSessionService.session_kind == "core": met_time_limit = BlockSessionService.get_current_elapsed_seconds() <= BlockSessionService.core_current_target_seconds QuestionBankService.record_first_attempt( question_id_for_attempt, attempted_choice, _presented_choice_ids(), int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())), met_time_limit ) var result: Dictionary if BlockSessionService.session_kind == "core": result = BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged) elif BlockSessionService.session_kind == "bookmark": result = BlockSessionService.submit_bookmark_attempt(selected_choice, eliminated, flagged) else: result = BlockSessionService.submit_review_attempt(selected_choice, eliminated, flagged) if result.is_empty(): return var answer: Dictionary = result.get("answer", {}) if BlockSessionService.session_kind == "review" and bool(result.get("first_submission", false)): ReviewQueueService.record_first_submission( BlockSessionService.get_current_question(), attempted_choice, _presented_choice_ids(), int(floor(float(answer.get("elapsed_seconds", 0.0))))) if bool(result.get("is_correct", false)): current_answer = answer _update_question_elo_label(true) _show_answer_review() _trigger_correct_feedback() var earned_coin := ( BlockSessionService.session_kind == "core" and bool(result.get("mastered", false)) and met_time_limit ) if earned_coin: var correct_choice_index := _choice_index_for_id(selected_choice) if correct_choice_index >= 0 and correct_choice_index < choice_rows.size(): _play_coin_burst(choice_rows[correct_choice_index].get_global_rect()) return _lock_wrong_choice(attempted_choice) _show_wrong_answer_followup(attempted_choice) selected_choice = "" current_answer = {} next_button.text = "Submit" next_button.disabled = true _trigger_wrong_feedback() _apply_saved_choice_state() return QuestionBankService.record_first_attempt( question_id_for_attempt, selected_choice, _presented_choice_ids(), int(floor(BlockSessionService.get_current_elapsed_seconds())) ) current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged) _show_answer_review() else: if BlockSessionService.session_kind == "dynamic": awaiting_next_question = true next_button.text = "Generating…" next_button.disabled = true DynamicQuestionService.load_next() elif BlockSessionService.session_kind == "core": _request_next_core_question() elif BlockSessionService.session_kind == "bookmark": if BookmarkService.has_next_practice(): awaiting_next_question = true next_button.text = "Loading..." next_button.disabled = true BookmarkService.next_practice() else: _finish() elif BlockSessionService.session_kind == "review": awaiting_next_question = true next_button.text = "Loading..." next_button.disabled = true ReviewQueueService.load_next() else: _advance_or_finish() func _on_dynamic_question_loaded(question: Dictionary) -> void: if not awaiting_next_question or BlockSessionService.session_kind != "dynamic": return if not BlockSessionService.replace_dynamic_question(question): _on_dynamic_question_failed("The next dynamic question could not be prepared.") return awaiting_next_question = false _load_question() UISoundService.play_sound("question") func _on_dynamic_question_failed(message: String) -> void: if not awaiting_next_question or BlockSessionService.session_kind != "dynamic": return awaiting_next_question = false next_button.text = "Retry" next_button.disabled = false next_button.tooltip_text = message func _request_next_core_question() -> void: awaiting_next_question = true next_button.text = "Loading..." next_button.disabled = true QuestionBankService.load_next_random_question(BlockSessionService.get_core_excluded_question_ids()) func _on_random_question_changed() -> void: if not awaiting_next_question or QuestionBankService.load_state != "ready": return var loaded_questions := QuestionBankService.get_all_questions() if loaded_questions.is_empty(): _on_random_question_failed("No additional questions are available.") return if not BlockSessionService.append_core_question(loaded_questions[0]): _on_random_question_failed("The next question could not be prepared.") return awaiting_next_question = false _load_question() UISoundService.play_sound("question") func _on_random_question_failed(message: String) -> void: if not awaiting_next_question: return if QuestionBankService.load_state == "retrying": next_button.text = "Reconnecting..." next_button.disabled = true next_button.tooltip_text = message return awaiting_next_question = false next_button.text = "Retry" next_button.disabled = false next_button.tooltip_text = message func _show_answer_review() -> void: if BlockSessionService.session_kind == "simulation": _clear_answer_review() return _show_explanation() _show_references(BlockSessionService.get_current_question().get("references", [])) _ensure_answer_review_followups() _render_answer_review_sections() _set_answer_review_timer_visibility() next_button.text = "Next" next_button.disabled = false if BlockSessionService.session_kind == "bookmark" and not BookmarkService.has_next_practice(): next_button.text = "End of list" next_button.disabled = true if skip_button != null: skip_button.disabled = true skip_button.visible = BlockSessionService.session_kind == "bookmark" skip_button.text = "Back to Bookmarks" if BlockSessionService.session_kind == "bookmark" else "Skip" skip_button.disabled = false if BlockSessionService.session_kind == "bookmark" else true if question_scroll != null: question_scroll.call_deferred("ensure_control_visible", explanation_label) _refresh_toolbar_state() func _set_answer_review_timer_visibility() -> void: var keep_core_timer := BlockSessionService.session_kind == "core" timer_bar.visible = keep_core_timer timer_label.visible = keep_core_timer timer_footer.visible = keep_core_timer if keep_core_timer: _update_timer_bar() func _show_explanation() -> void: var question := BlockSessionService.get_current_question() for button in choice_buttons: button.disabled = true var correct_id := str(question.get("correct_choice_id", "")) _dock_correct_review_panel(correct_id) correct_review_panel.visible = true var choices := _get_presented_choices() for index in range(choices.size()): var choice: Dictionary = choices[index] var id := str(choice.get("id", "")) _set_choice_button_text(index, choice) _style_choice_button_for_state(index, id) explanation_plain_text = str(question.get("explanation", "")) _render_explanation() explanation_label.visible = true category_label.visible = not category_label.text.strip_edges().is_empty() _update_question_elo_label(true) _refresh_nav_palette() _update_content_vertical_alignment() func _dock_correct_review_panel(correct_id: String) -> void: var index := _choice_index_for_id(correct_id) if index < 0 or index >= choice_rows.size(): return if correct_review_panel.get_parent() != choice_rows[index]: correct_review_panel.reparent(choice_rows[index]) func _render_answer_review_sections() -> void: _render_teaching_cards() _render_review_followups() _render_unselected_distractor_explanations() func _clear_answer_review() -> void: if explanation_label != null: explanation_label.visible = false if correct_review_panel != null: correct_review_panel.visible = false if category_label != null: category_label.visible = false _clear_container_children(teaching_cards) _clear_container_children(review_followups_content) review_followup_states.clear() if review_followups_section != null: review_followups_section.visible = false _update_content_vertical_alignment() func _update_content_vertical_alignment() -> void: if question_content == null: return question_content.alignment = BoxContainer.ALIGNMENT_BEGIN func _clear_container_children(container: Control) -> void: if container == null: return for child in container.get_children(): container.remove_child(child) child.queue_free() func _render_teaching_cards() -> void: if teaching_cards == null: return _clear_container_children(teaching_cards) var question := BlockSessionService.get_current_question() var takeaway := str(question.get("key_takeaway", "")).strip_edges() if takeaway == "": var correct_id := str(question.get("correct_choice_id", "")) var explanations: Dictionary = question.get("choice_explanations", {}) takeaway = _strip_verdict_prefix(str(explanations.get(correct_id, "")).strip_edges()) var pearl := str(question.get("usmle_pearl", "")).strip_edges() if takeaway != "": _add_teaching_card("KEY TAKEAWAY", takeaway, Brand.SKY_BLUE) if pearl != "": _add_teaching_card("USMLE PEARL", pearl, Brand.PURPLE) teaching_cards.visible = teaching_cards.get_child_count() > 0 func _add_teaching_card(title_text: String, body_text: String, accent: Color) -> void: var dark := _followup_is_dark() var card := PanelContainer.new() card.custom_minimum_size = Vector2(360, 0) card.size_flags_horizontal = Control.SIZE_EXPAND_FILL card.add_theme_stylebox_override("panel", _teaching_card_box(dark, accent)) teaching_cards.add_child(card) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 6) card.add_child(content) var heading := Label.new() heading.text = title_text _set_scaled_label(heading, 13, accent) content.add_child(heading) var body := Label.new() body.text = body_text body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(body, 14, Brand.text_color(dark)) content.add_child(body) func _render_review_followups() -> void: if review_followups_content == null: return _clear_container_children(review_followups_content) if BlockSessionService.session_kind == "simulation": review_followups_section.visible = false return if not BlockSessionService.show_review_followups: review_followups_section.visible = false return var question := BlockSessionService.get_current_question() var authored: Array = question.get("review_followups", []) if review_followup_states.size() != authored.size(): review_followup_states.clear() for _index in range(authored.size()): review_followup_states.append({"selected_choice_id": "", "answered": false}) review_followups_section.visible = not authored.is_empty() if authored.is_empty(): return var answered_count := 0 for state in review_followup_states: if bool(state.get("answered", false)): answered_count += 1 var heading := find_child("ReviewFollowupHeading", true, false) as Label if heading != null: heading.text = "FOLLOW-UP QUESTIONS | %d of %d answered" % [answered_count, authored.size()] for index in range(authored.size()): if typeof(authored[index]) != TYPE_DICTIONARY: continue _add_review_followup_card(index, authored[index], review_followup_states[index]) func _add_review_followup_card(index: int, followup: Dictionary, state: Dictionary) -> void: var dark := _followup_is_dark() var answered := bool(state.get("answered", false)) var selected_id := str(state.get("selected_choice_id", "")) var correct_id := str(followup.get("correct_choice_id", "")) var is_correct := answered and selected_id == correct_id var accent := Brand.SUCCESS if is_correct else Color("fb7185") if answered else Brand.PURPLE var card := PanelContainer.new() card.add_theme_stylebox_override("panel", _review_followup_box(dark, accent, answered)) review_followups_content.add_child(card) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 8) card.add_child(content) var prompt := Label.new() prompt.text = "%d. %s" % [index + 1, str(followup.get("prompt", ""))] prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(prompt, 15, Brand.text_color(dark)) content.add_child(prompt) var choices: Array = followup.get("choices", []) for option in choices: if typeof(option) != TYPE_DICTIONARY: continue var option_id := str(option.get("id", "")) var option_button := Button.new() option_button.text = "%s. %s" % [option_id, str(option.get("text", ""))] option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT option_button.disabled = answered option_button.custom_minimum_size = Vector2(0, 38) _set_scaled_button_font(option_button, 14) _style_review_followup_option(option_button, dark, option_id, selected_id, correct_id, answered) option_button.pressed.connect(_answer_review_followup.bind(index, option_id)) content.add_child(option_button) if answered: var teaching_point := Label.new() teaching_point.text = str(followup.get("teaching_point", "")) teaching_point.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(teaching_point, 14, accent) content.add_child(teaching_point) func _answer_review_followup(index: int, choice_id: String) -> void: if index < 0 or index >= review_followup_states.size(): return var state: Dictionary = review_followup_states[index] if bool(state.get("answered", false)): return state["selected_choice_id"] = choice_id state["answered"] = true review_followup_states[index] = state _render_review_followups() func _render_unselected_distractor_explanations() -> void: # Choices the user never picked (so never triggered the wrong-answer # follow-up quiz) still need their "why incorrect" explanation shown - # nest it directly in that choice's own card instead of a repeated # aggregate list at the bottom. Choices that DID trigger a follow-up # already show this same explanation inside the resolved quiz view. # Guard against ever rendering on a question that hasn't actually been # answered yet, regardless of what triggered this call. if current_answer.is_empty() and not pending_correct_advance: return var question := BlockSessionService.get_current_question() var correct_id := str(question.get("correct_choice_id", "")) var choices := _get_presented_choices() for index in range(choices.size()): if index >= choice_followup_panels.size(): continue var choice: Dictionary = choices[index] if typeof(choice) != TYPE_DICTIONARY: continue var choice_id := str(choice.get("id", "")) if choice_id == "" or choice_id == correct_id or followup_states.has(choice_id): continue _clear_followup_content(index) var content := choice_followup_contents[index] var explanation := Label.new() explanation.text = _strip_verdict_prefix(_distractor_explanation_text(choice_id)) explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(explanation, 14, Brand.text_color(_followup_is_dark())) content.add_child(explanation) choice_followup_panels[index].add_theme_stylebox_override( "panel", _attached_panel_box(_followup_is_dark(), Color("fb7185")) ) choice_followup_panels[index].visible = true _style_choice_button_for_state(index, choice_id) func _distractor_explanation_text(choice_id: String) -> String: var question := BlockSessionService.get_current_question() var explanations: Dictionary = question.get("choice_explanations", {}) var explanation := str(explanations.get(choice_id, "")).strip_edges() if explanation != "": return explanation return "Review the stem finding that makes this option less appropriate." func _strip_verdict_prefix(value: String) -> String: if value.begins_with("Correct. "): return value.substr(9) if value.begins_with("Incorrect. "): return value.substr(11) return value func _show_references(raw_references: Variant) -> void: _clear_references() if typeof(raw_references) != TYPE_ARRAY: return var valid_references: Array = [] for reference in raw_references: if typeof(reference) != TYPE_DICTIONARY: continue var url := str(reference.get("url", "")).strip_edges() if not _is_safe_reference_url(url): continue valid_references.append(reference) if valid_references.is_empty(): return var heading := Label.new() heading.text = "References" _set_scaled_label(heading, 18, Style.TEXT) references_container.add_child(heading) for reference in valid_references: var title := str(reference.get("title", "Reference")).strip_edges() var publisher := str(reference.get("publisher", "")).strip_edges() var url := str(reference.get("url", "")).strip_edges() var link := LinkButton.new() link.text = title if publisher == "" else "%s - %s" % [title, publisher] link.tooltip_text = url link.size_flags_horizontal = Control.SIZE_EXPAND_FILL link.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS link.add_theme_color_override("font_color", Style.CYAN) link.add_theme_color_override("font_hover_color", Style.MAGENTA) link.set_meta("question_base_font_size", 16) link.add_theme_font_size_override("font_size", _scaled_font_size(16)) link.pressed.connect(_open_reference_url.bind(url)) references_container.add_child(link) references_container.visible = true func _clear_references() -> void: if references_container == null: return for child in references_container.get_children(): references_container.remove_child(child) child.queue_free() references_container.visible = false func _is_safe_reference_url(url: String) -> bool: return url.begins_with("https://") and url.length() > 8 func _open_reference_url(url: String) -> void: if not _is_safe_reference_url(url): return OS.shell_open(url) func _advance_or_finish() -> void: if BlockSessionService.move_next(): _load_question() else: _finish() func _finish() -> void: if BlockSessionService.session_kind == "bookmark": BlockSessionService.finish_block() main.show_screen("bookmarks") return if BlockSessionService.session_kind == "core": BlockSessionService.finish_block() main.show_screen("selection", "exam") return if BlockSessionService.session_kind == "review": BlockSessionService.finish_block() main.show_screen("review_queue_results", ReviewQueueService.get_summary()) return var results := BlockSessionService.finish_block() main.show_screen("review", results) func _exit_run() -> void: if BlockSessionService.session_kind == "bookmark": BlockSessionService.finish_block() main.show_screen("bookmarks") return if BlockSessionService.session_kind in ["review", "dynamic"]: BlockSessionService.finish_block() DashboardService.refresh() main.show_screen("dashboard") return var was_core_run := BlockSessionService.session_kind == "core" BlockSessionService.resume_current_question_timer() BlockSessionService.finish_block() main.show_screen("selection", "exam" if was_core_run else "step3") func _update_timer_bar() -> void: if timer_bar == null: return var is_core_timer := BlockSessionService.session_kind == "core" var ratio := BlockSessionService.get_session_timer_ratio() var next_value := ratio * 100.0 if last_timer_bar_value < 0.0 or absf(next_value - last_timer_bar_value) >= 0.1: last_timer_bar_value = next_value timer_bar.value = next_value if is_core_timer and timer_label.visible: _set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))) var color_state := "blue" var fill_color := Brand.ROYAL_BLUE if ratio < 0.25: color_state = "red" fill_color = Style.RED elif ratio <= 0.5: color_state = "amber" fill_color = Style.AMBER if color_state != timer_color_state: timer_color_state = color_state timer_bar.add_theme_stylebox_override("fill", Style.button_box(fill_color, fill_color)) func _set_timer_display(total_seconds: int) -> void: if timer_label == null: return var safe_seconds := maxi(0, total_seconds) if safe_seconds == last_timer_display_seconds: return last_timer_display_seconds = safe_seconds timer_label.text = "%02d:%02d" % [int(floor(float(safe_seconds) / 60.0)), safe_seconds % 60] func _is_session_timer_visible() -> bool: if BlockSessionService.session_kind == "core" or BlockSessionService.session_kind == "simulation": return true return BlockSessionService.mode == "Timed" func _question_progress_text() -> String: if BlockSessionService.session_kind == "core": var block_size := BlockSessionService.core_block_size return "%d of %d" % [(BlockSessionService.core_answer_count % block_size) + 1, block_size] return "%d of %d" % [ mini(BlockSessionService.current_index + 1, BlockSessionService.active_questions.size()), BlockSessionService.active_questions.size() ] func _update_progress_indicators() -> void: # "core" sessions have no fixed total (endless queue), so there's no meaningful # ratio/percentage to show - hide the bars rather than showing a misleading value. var show_progress := BlockSessionService.session_kind != "core" if progress_bar != null: progress_bar.visible = show_progress if progress_percent_label != null: progress_percent_label.visible = show_progress if bottom_progress_bar != null: bottom_progress_bar.visible = show_progress if bottom_progress_label != null: bottom_progress_label.visible = show_progress if not show_progress: return var total := BlockSessionService.active_questions.size() var current := mini(BlockSessionService.current_index + 1, maxi(total, 1)) var ratio := float(current) / float(maxi(total, 1)) var percent := int(round(ratio * 100.0)) if progress_bar != null: progress_bar.value = ratio * 100.0 if progress_percent_label != null: progress_percent_label.text = "%d%%" % percent if bottom_progress_bar != null: bottom_progress_bar.value = ratio * 100.0 if bottom_progress_label != null: bottom_progress_label.text = "%d of %d" % [current, total] func _toggle_question_navigator() -> void: if navigator_popup == null or BlockSessionService.session_kind == "core": return if navigator_popup.visible: navigator_popup.hide() return _refresh_nav_palette() var popup_size := Vector2i(340, 200) var anchor := progress_button.get_global_rect() var viewport_size := get_viewport_rect().size var popup_x := int(clampf(anchor.position.x, 8.0, maxf(8.0, viewport_size.x - popup_size.x - 8.0))) var popup_y := int(minf(anchor.end.y + 6.0, viewport_size.y - popup_size.y - 8.0)) navigator_popup.popup(Rect2i(Vector2i(popup_x, popup_y), popup_size)) func _navigate_to_question(target_index: int) -> void: if target_index == BlockSessionService.current_index: navigator_popup.hide() return if BlockSessionService.session_kind == "simulation": _save_simulation_current_response() if BlockSessionService.move_to(target_index): navigator_popup.hide() _load_question() func _lock_wrong_choice(choice_id: String) -> void: if choice_id == "": return if not locked_wrong_choice_ids.has(choice_id): locked_wrong_choice_ids.append(choice_id) func _trigger_wrong_feedback() -> void: UISoundService.play_sound("error") shake_time = 0.28 shake_origin = position green_fog_time = 0.0 red_fog_time = FEEDBACK_FADE_SECONDS if feedback_fog != null: feedback_fog.color = Color(1.0, 0.0, 0.0, _feedback_overlay_alpha(false)) func _trigger_correct_feedback() -> void: UISoundService.play_sound("correct") red_fog_time = 0.0 green_fog_time = FEEDBACK_FADE_SECONDS if skip_button != null: skip_button.disabled = true for button in choice_buttons: button.disabled = true if feedback_fog != null: feedback_fog.color = Color(0.25, 1.0, 0.25, _feedback_overlay_alpha(true)) func _play_coin_burst(origin_rect: Rect2) -> void: if credits_badge == null: return var origin := origin_rect.get_center() var target := credits_badge.get_global_rect().get_center() var coin_count := 6 var flight_time := 0.55 var stagger := 0.035 for index in range(coin_count): var coin := TextureRect.new() coin.texture = COIN_ICON coin.modulate = Brand.AMBER coin.custom_minimum_size = Vector2(16, 16) coin.size = Vector2(16, 16) coin.top_level = true coin.mouse_filter = Control.MOUSE_FILTER_IGNORE coin.pivot_offset = Vector2(8, 8) var jitter := Vector2(randf_range(-24.0, 24.0), randf_range(-16.0, 16.0)) coin.global_position = origin + jitter - coin.size * 0.5 add_child(coin) var tween := create_tween() tween.tween_interval(index * stagger) tween.set_parallel(true) tween.tween_property(coin, "global_position", target - coin.size * 0.5, flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN) tween.tween_property(coin, "scale", Vector2(0.6, 0.6), flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN) tween.set_parallel(false) tween.tween_callback(coin.queue_free) var settle_time := (coin_count - 1) * stagger + flight_time get_tree().create_timer(settle_time).timeout.connect(func(): if credits_badge != null: credits_badge.bump()) func _update_answer_feedback(delta: float) -> void: if shake_time > 0.0: shake_time = max(0.0, shake_time - delta) if shake_time > 0.0: position = shake_origin + Vector2(randf_range(-9.0, 9.0), randf_range(-4.0, 4.0)) else: position = shake_origin if red_fog_time > 0.0 and feedback_fog != null: red_fog_time = max(0.0, red_fog_time - delta) var red_alpha := _feedback_overlay_alpha(false) * (red_fog_time / FEEDBACK_FADE_SECONDS) feedback_fog.color = Color(1.0, 0.0, 0.0, red_alpha) elif green_fog_time > 0.0 and feedback_fog != null: green_fog_time = max(0.0, green_fog_time - delta) var green_alpha := _feedback_overlay_alpha(true) * (green_fog_time / FEEDBACK_FADE_SECONDS) feedback_fog.color = Color(0.25, 1.0, 0.25, green_alpha) if pending_correct_advance: correct_advance_delay = max(0.0, correct_advance_delay - delta) if correct_advance_delay <= 0.0: pending_correct_advance = false next_button.disabled = false if skip_button != null: skip_button.disabled = false _advance_or_finish() func _feedback_overlay_alpha(is_correct: bool) -> float: if Style.is_dark: return 0.23 if is_correct else 0.26 return 0.07 func _reset_wrong_feedback() -> void: if shake_time > 0.0: position = shake_origin shake_time = 0.0 red_fog_time = 0.0 green_fog_time = 0.0 correct_advance_delay = 0.0 pending_correct_advance = false next_button.disabled = false if skip_button != null: skip_button.disabled = false if feedback_fog != null: feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0) func _commit_current_text_selection(source: String = "stem") -> void: var target_label := stem_label if source == "stem" else explanation_label if target_label == null: return var raw_selected_text := target_label.get_selected_text() var selected_text := raw_selected_text.strip_edges() if selected_text == "": return var left_trimmed_text := raw_selected_text.strip_edges(true, false) var leading_trim_count := raw_selected_text.length() - left_trimmed_text.length() var selection_start := target_label.get_selection_from() + leading_trim_count _highlight_range(source, selection_start, selection_start + selected_text.length()) func _highlight_range(source: String, start: int, end: int) -> void: var question_id := str(BlockSessionService.get_current_question().get("id", "")) var highlight_store := question_highlights if source == "stem" else explanation_highlights var plain_text := stem_plain_text if source == "stem" else explanation_plain_text var target_label := stem_label if source == "stem" else explanation_label if not highlight_store.has(question_id): highlight_store[question_id] = [] var highlights: Array = highlight_store[question_id] start = clamp(start, 0, plain_text.length()) end = clamp(end, start, plain_text.length()) if start == end: return if _subtract_highlight_range(highlights, start, end): _render_highlighted_text(source) if target_label.has_method("deselect"): target_label.deselect() return highlights.append({"start": start, "end": end}) _render_highlighted_text(source) if target_label.has_method("deselect"): target_label.deselect() func _clear_current_highlights() -> void: var question_id := str(BlockSessionService.get_current_question().get("id", "")) question_highlights[question_id] = [] explanation_highlights[question_id] = [] _render_stem() if explanation_label.visible: _render_explanation() func _toggle_report_current_question() -> void: var question_id := str(BlockSessionService.get_current_question().get("id", "")) if question_id == "": return _show_report_modal(question_id) func _refresh_toolbar_state() -> void: _refresh_theme_button() _refresh_labs_button() if report_button != null: var question_id := str(BlockSessionService.get_current_question().get("id", "")) report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question" if notes_button != null: notes_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"] if flashcard_button != null: flashcard_button.visible = Brand.is_uat_environment() and BlockSessionService.session_kind not in ["simulation", "dynamic"] flashcard_button.disabled = current_answer.is_empty() if bookmark_button != null: bookmark_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"] var bookmark_question_id := str(BlockSessionService.get_current_question().get("id", "")) var bookmark_value := BookmarkService.is_bookmarked(bookmark_question_id) var bookmark_pending := BookmarkService.is_pending(bookmark_question_id) bookmark_button.tooltip_text = ("Saved; waiting to sync" if bookmark_pending else "Remove bookmark") if bookmark_value else ("Removal waiting to sync" if bookmark_pending else "Save question") if calculator_button != null: calculator_button.visible = true _apply_question_theme() func _toggle_calculator() -> void: if calculator_panel != null: calculator_panel.toggle_panel() func _toggle_bookmark_current_question() -> void: var question := BlockSessionService.get_current_question() if not question.is_empty(): BookmarkService.toggle_question(question) func _toggle_flashcard_current_question() -> void: if current_answer.is_empty(): return var question := BlockSessionService.get_current_question() if question.is_empty() or flashcard_prefill_dialog == null: return flashcard_prefill_dialog.open_for_question(question, current_answer) func _on_bookmark_status_changed(question_id: String, value: bool, pending: bool) -> void: if question_id != str(BlockSessionService.get_current_question().get("id", "")) or bookmark_button == null: return bookmark_button.tooltip_text = ("Saved; waiting to sync" if pending else "Remove bookmark") if value else ("Removal waiting to sync" if pending else "Save question") _apply_question_theme() func _on_bookmark_question_loaded(question: Dictionary) -> void: if not visible or BlockSessionService.session_kind != "bookmark" or not awaiting_next_question: return awaiting_next_question = false if BlockSessionService.replace_bookmark_question(question): _load_question() func _on_bookmark_question_failed(message: String) -> void: if not visible or BlockSessionService.session_kind != "bookmark": return awaiting_next_question = false next_button.text = "Retry" next_button.disabled = false next_button.tooltip_text = message func _on_review_question_loaded(question: Dictionary) -> void: if not visible or BlockSessionService.session_kind != "review" or not awaiting_next_question: return awaiting_next_question = false if BlockSessionService.replace_review_question(question): _load_question() UISoundService.play_sound("question") func _on_review_question_failed(message: String) -> void: if not visible or BlockSessionService.session_kind != "review": return awaiting_next_question = false next_button.text = "Retry" next_button.disabled = false next_button.tooltip_text = message func _on_review_pass_completed(summary: Dictionary) -> void: if not visible or BlockSessionService.session_kind != "review": return awaiting_next_question = false BlockSessionService.finish_block() main.show_screen("review_queue_results", summary) func refresh_theme_controls() -> void: if media_viewer_background != null: media_viewer_background.color = Style.BACKGROUND _refresh_toolbar_state() _apply_saved_choice_state() _apply_question_theme() func _refresh_theme_button() -> void: if theme_button == null: return _style_theme_toggle_button(theme_service.is_dark_mode()) func _adjust_question_text_scale(delta: float) -> void: var next_scale: float = clamp(question_text_scale + delta, TEXT_SCALE_MIN, TEXT_SCALE_MAX) if is_equal_approx(next_scale, question_text_scale): return question_text_scale = next_scale AccountProfileService.update_text_scale(next_scale) _refresh_scaled_fonts(self) _render_stem() _apply_saved_choice_state() func _synced_question_text_scale() -> float: var saved: float = AccountProfileService.preferences.get("questionTextScale", 1.0) return clamp(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX) func _on_account_profile_changed() -> void: var synced := _synced_question_text_scale() if is_equal_approx(synced, question_text_scale): return question_text_scale = synced _refresh_scaled_fonts(self) _render_stem() _apply_saved_choice_state() func _set_scaled_label(label: Label, base_size: int, color: Color = Style.TEXT) -> void: label.set_meta("question_base_font_size", base_size) label.add_theme_color_override("font_color", color) label.add_theme_font_size_override("font_size", _scaled_font_size(base_size)) func _set_scaled_button_font(button: Button, base_size: int) -> void: button.set_meta("question_base_font_size", base_size) button.add_theme_font_size_override("font_size", _scaled_font_size(base_size)) func _set_scaled_rich_text(label: RichTextLabel, base_size: int) -> void: label.set_meta("question_base_font_size", base_size) label.add_theme_color_override("default_color", Style.TEXT) label.add_theme_font_size_override("normal_font_size", _scaled_font_size(base_size)) label.add_theme_font_size_override("bold_font_size", _scaled_font_size(base_size)) func _refresh_scaled_fonts(node: Node) -> void: if node.has_meta("question_base_font_size"): var base_size := int(node.get_meta("question_base_font_size")) if node is Label: node.add_theme_font_size_override("font_size", _scaled_font_size(base_size)) elif node is Button: node.add_theme_font_size_override("font_size", _scaled_font_size(base_size)) elif node is LinkButton: node.add_theme_font_size_override("font_size", _scaled_font_size(base_size)) elif node is RichTextLabel: node.add_theme_font_size_override("normal_font_size", _scaled_font_size(base_size)) node.add_theme_font_size_override("bold_font_size", _scaled_font_size(base_size)) for child in node.get_children(): _refresh_scaled_fonts(child) func _scaled_font_size(base_size: int) -> int: return int(round(float(base_size) * question_text_scale)) func _refresh_labs_button() -> void: if labs_button == null: return labs_button.visible = _current_question_has_labs() func _current_question_has_labs() -> bool: var question := BlockSessionService.get_current_question() if question.is_empty(): return false var labs: Array = question.get("labs", []) return not labs.is_empty() func _load_question_media(question: Dictionary) -> void: _clear_question_media() var media: Array = question.get("media", []) if media.is_empty(): return media_container.visible = true var question_id := str(question.get("id", "")) for item in media: var media_url := _resolve_media_url(str(item)) if media_url == "": continue var media_index := question_media_items.size() question_media_items.append({"texture": null, "loaded": false, "failed": false}) var frame := PanelContainer.new() frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL frame.add_theme_stylebox_override("panel", Style.panel_box(Color("111a2a"), Color("344463"))) media_container.add_child(frame) var image_box := VBoxContainer.new() image_box.add_theme_constant_override("separation", 8) frame.add_child(image_box) var status := Label.new() status.text = "Loading image..." status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _set_scaled_label(status, 15, Style.MUTED) image_box.add_child(status) var preview := Control.new() preview.visible = false preview.custom_minimum_size = Vector2(0, 260) preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL preview.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND preview.tooltip_text = "Open enlarged image" preview.gui_input.connect(func(event: InputEvent): _media_preview_gui_input(event, media_index)) image_box.add_child(preview) var texture_rect := TextureRect.new() texture_rect.visible = false texture_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE texture_rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED preview.add_child(texture_rect) var request := HTTPRequest.new() request.request_completed.connect( func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray): _on_media_loaded(result, response_code, body, question_id, media_index, preview, texture_rect, status) ) media_requests.append(request) add_child(request) var err := request.request(media_url) if err != OK: status.text = "Image unavailable." func _clear_question_media() -> void: _hide_media_viewer() for request in media_requests: if is_instance_valid(request): request.queue_free() media_requests.clear() question_media_items.clear() if media_container == null: return for child in media_container.get_children(): media_container.remove_child(child) child.queue_free() media_container.visible = false func _resolve_media_url(value: String) -> String: var media_url := value.strip_edges() if media_url == "": return "" if media_url.begins_with("http://") or media_url.begins_with("https://"): return media_url if not media_url.begins_with("/"): media_url = "/%s" % media_url var base_url := QuestionBankService.question_api_base_url var scheme_end := base_url.find("://") if scheme_end < 0: return media_url var path_start := base_url.find("/", scheme_end + 3) var origin := base_url if path_start < 0 else base_url.substr(0, path_start) return "%s%s" % [origin, media_url] func _on_media_loaded( result: int, response_code: int, body: PackedByteArray, question_id: String, media_index: int, preview: Control, texture_rect: TextureRect, status: Label) -> void: if str(BlockSessionService.get_current_question().get("id", "")) != question_id: return if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: status.text = "Image unavailable." if media_index < question_media_items.size(): question_media_items[media_index]["failed"] = true return var image := Image.new() var err := image.load_png_from_buffer(body) if err != OK: err = image.load_jpg_from_buffer(body) if err != OK: err = image.load_webp_from_buffer(body) if err != OK: status.text = "Image unavailable." if media_index < question_media_items.size(): question_media_items[media_index]["failed"] = true return var texture := ImageTexture.create_from_image(image) question_media_items[media_index]["texture"] = texture question_media_items[media_index]["loaded"] = true texture_rect.texture = texture texture_rect.visible = true preview.visible = true status.visible = false func _build_media_viewer() -> void: media_viewer_overlay = Control.new() media_viewer_overlay.visible = false media_viewer_overlay.top_level = true media_viewer_overlay.mouse_filter = Control.MOUSE_FILTER_STOP media_viewer_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(media_viewer_overlay) media_viewer_background = ColorRect.new() media_viewer_background.color = Style.BACKGROUND media_viewer_background.mouse_filter = Control.MOUSE_FILTER_STOP media_viewer_background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) media_viewer_overlay.add_child(media_viewer_background) var layout := VBoxContainer.new() layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) layout.offset_left = 16 layout.offset_top = 12 layout.offset_right = -16 layout.offset_bottom = -16 layout.add_theme_constant_override("separation", 10) media_viewer_overlay.add_child(layout) var toolbar := HBoxContainer.new() toolbar.add_theme_constant_override("separation", 8) layout.add_child(toolbar) media_viewer_position_label = Label.new() media_viewer_position_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _set_scaled_label(media_viewer_position_label, 17, Style.TEXT) toolbar.add_child(media_viewer_position_label) media_viewer_previous_button = _make_media_viewer_button("<", "Previous image") media_viewer_previous_button.pressed.connect(func(): _show_adjacent_media(-1)) toolbar.add_child(media_viewer_previous_button) media_viewer_next_button = _make_media_viewer_button(">", "Next image") media_viewer_next_button.pressed.connect(func(): _show_adjacent_media(1)) toolbar.add_child(media_viewer_next_button) var zoom_out := _make_media_viewer_button("-", "Zoom out") zoom_out.pressed.connect(func(): _set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR)) toolbar.add_child(zoom_out) media_viewer_zoom_label = Label.new() media_viewer_zoom_label.custom_minimum_size = Vector2(64, 44) media_viewer_zoom_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER media_viewer_zoom_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _set_scaled_label(media_viewer_zoom_label, 15, Style.TEXT) toolbar.add_child(media_viewer_zoom_label) var zoom_in := _make_media_viewer_button("+", "Zoom in") zoom_in.pressed.connect(func(): _set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR)) toolbar.add_child(zoom_in) var fit_button := _make_media_viewer_button("Fit", "Fit image to window", Vector2(60, 44)) fit_button.pressed.connect(_fit_media_viewer_image) toolbar.add_child(fit_button) var close_button := _make_media_viewer_button("X", "Close image viewer") close_button.pressed.connect(_hide_media_viewer) toolbar.add_child(close_button) media_viewer_scroll = ScrollContainer.new() media_viewer_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL media_viewer_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL media_viewer_scroll.gui_input.connect(_media_viewer_gui_input) layout.add_child(media_viewer_scroll) media_viewer_center = CenterContainer.new() media_viewer_center.size_flags_horizontal = Control.SIZE_EXPAND_FILL media_viewer_center.size_flags_vertical = Control.SIZE_EXPAND_FILL media_viewer_scroll.add_child(media_viewer_center) media_viewer_texture = TextureRect.new() media_viewer_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE media_viewer_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED media_viewer_texture.mouse_filter = Control.MOUSE_FILTER_IGNORE media_viewer_center.add_child(media_viewer_texture) func _make_media_viewer_button(text_value: String, tooltip: String, minimum := Vector2(44, 44)) -> Button: var button := Button.new() button.text = text_value button.tooltip_text = tooltip button.custom_minimum_size = minimum Style.style_chip(button, Style.CYAN) UISoundService.set_button_sound(button, "toggle") return button func _media_preview_gui_input(event: InputEvent, media_index: int) -> void: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: _show_media_viewer(media_index) accept_event() elif event is InputEventScreenTouch and event.pressed: _show_media_viewer(media_index) accept_event() func _show_media_viewer(index: int) -> void: if index < 0 or index >= question_media_items.size(): return if not bool(question_media_items[index].get("loaded", false)): return var texture := question_media_items[index].get("texture") as Texture2D if texture == null: return media_viewer_index = index media_viewer_texture.texture = texture media_viewer_overlay.visible = true media_viewer_overlay.move_to_front() _update_media_viewer_navigation() call_deferred("_fit_media_viewer_image") func _hide_media_viewer() -> void: media_viewer_dragging = false media_viewer_index = -1 if media_viewer_overlay != null: media_viewer_overlay.visible = false func _show_adjacent_media(direction: int) -> void: var next_index := _find_loaded_media_index(media_viewer_index, direction) if next_index >= 0: _show_media_viewer(next_index) func _find_loaded_media_index(from_index: int, direction: int) -> int: var index := from_index + direction while index >= 0 and index < question_media_items.size(): if bool(question_media_items[index].get("loaded", false)): return index index += direction return -1 func _update_media_viewer_navigation() -> void: media_viewer_position_label.text = "Image %d of %d" % [media_viewer_index + 1, question_media_items.size()] media_viewer_previous_button.disabled = _find_loaded_media_index(media_viewer_index, -1) < 0 media_viewer_next_button.disabled = _find_loaded_media_index(media_viewer_index, 1) < 0 func _fit_media_viewer_image() -> void: if media_viewer_texture == null or media_viewer_texture.texture == null: return var image_size := media_viewer_texture.texture.get_size() var available := media_viewer_scroll.size - Vector2(24, 24) if image_size.x <= 0.0 or image_size.y <= 0.0 or available.x <= 0.0 or available.y <= 0.0: return _set_media_viewer_zoom(minf(available.x / image_size.x, available.y / image_size.y)) func _set_media_viewer_zoom(value: float) -> void: if media_viewer_texture == null or media_viewer_texture.texture == null: return media_viewer_zoom = clampf(value, MEDIA_ZOOM_MIN, MEDIA_ZOOM_MAX) var scaled_size := media_viewer_texture.texture.get_size() * media_viewer_zoom media_viewer_texture.custom_minimum_size = scaled_size media_viewer_center.custom_minimum_size = Vector2( maxf(media_viewer_scroll.size.x, scaled_size.x), maxf(media_viewer_scroll.size.y, scaled_size.y)) media_viewer_zoom_label.text = "%d%%" % int(round(media_viewer_zoom * 100.0)) func _media_viewer_gui_input(event: InputEvent) -> void: if event is InputEventMouseButton: if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed: _set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR) accept_event() elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed: _set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR) accept_event() elif event.button_index == MOUSE_BUTTON_LEFT: media_viewer_dragging = event.pressed media_viewer_drag_position = event.position accept_event() elif event is InputEventMouseMotion and media_viewer_dragging: var drag_delta: Vector2 = event.position - media_viewer_drag_position media_viewer_drag_position = event.position media_viewer_scroll.scroll_horizontal -= int(drag_delta.x) media_viewer_scroll.scroll_vertical -= int(drag_delta.y) accept_event() elif event is InputEventMagnifyGesture: _set_media_viewer_zoom(media_viewer_zoom * event.factor) accept_event() elif event is InputEventScreenDrag: media_viewer_scroll.scroll_horizontal -= int(event.relative.x) media_viewer_scroll.scroll_vertical -= int(event.relative.y) accept_event() func _build_report_modal() -> void: report_overlay = Control.new() report_overlay.visible = false report_overlay.top_level = true report_overlay.mouse_filter = Control.MOUSE_FILTER_STOP report_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) report_overlay.gui_input.connect(_report_overlay_gui_input) add_child(report_overlay) var dim := ColorRect.new() dim.color = Color(0.02, 0.03, 0.05, 0.78) dim.mouse_filter = Control.MOUSE_FILTER_IGNORE dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) report_overlay.add_child(dim) var center := CenterContainer.new() center.mouse_filter = Control.MOUSE_FILTER_IGNORE center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) report_overlay.add_child(center) report_panel = PanelContainer.new() report_panel.custom_minimum_size = Vector2(700, 600) report_panel.mouse_filter = Control.MOUSE_FILTER_STOP report_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.RED)) center.add_child(report_panel) var modal_body := VBoxContainer.new() modal_body.add_theme_constant_override("separation", 14) report_panel.add_child(modal_body) var header := HBoxContainer.new() header.add_theme_constant_override("separation", 10) modal_body.add_child(header) var title := Label.new() title.text = "Report question" title.size_flags_horizontal = Control.SIZE_EXPAND_FILL _set_scaled_label(title, 26, Style.TEXT) header.add_child(title) var close_button := Button.new() close_button.text = "X" close_button.tooltip_text = "Close report form" close_button.custom_minimum_size = Vector2(42, 38) Style.style_chip(close_button, Style.RED) UISoundService.set_button_sound(close_button, "close") close_button.pressed.connect(_hide_report_modal) header.add_child(close_button) report_form_content = VBoxContainer.new() report_form_content.add_theme_constant_override("separation", 12) modal_body.add_child(report_form_content) var report_category_label := Label.new() report_category_label.text = "What is the problem?" _set_scaled_label(report_category_label, 18, Style.CYAN) report_form_content.add_child(report_category_label) var category_grid := GridContainer.new() category_grid.columns = 2 category_grid.add_theme_constant_override("h_separation", 18) category_grid.add_theme_constant_override("v_separation", 8) report_form_content.add_child(category_grid) report_category_group = ButtonGroup.new() report_category_group.allow_unpress = false for category in REPORT_CATEGORIES: var category_button := CheckBox.new() category_button.text = str(category["label"]) category_button.set_meta("report_category_id", str(category["id"])) category_button.button_group = report_category_group category_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL category_button.custom_minimum_size = Vector2(0, 38) _set_scaled_button_font(category_button, 16) category_button.toggled.connect(_on_report_category_toggled) category_grid.add_child(category_button) report_category_buttons.append(category_button) var details_header := HBoxContainer.new() report_form_content.add_child(details_header) var details_label := Label.new() details_label.text = "Details" details_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _set_scaled_label(details_label, 18, Style.CYAN) details_header.add_child(details_label) report_char_count = Label.new() report_char_count.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _set_scaled_label(report_char_count, 14, Style.MUTED) details_header.add_child(report_char_count) report_details = TextEdit.new() report_details.placeholder_text = "Describe what is wrong and what should be reviewed." report_details.custom_minimum_size = Vector2(0, 190) report_details.size_flags_vertical = Control.SIZE_EXPAND_FILL report_details.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY report_details.add_theme_font_size_override("font_size", 16) report_details.add_theme_color_override("font_color", Style.TEXT) report_details.add_theme_color_override("font_placeholder_color", Style.MUTED) report_details.add_theme_stylebox_override("normal", Style.panel_box(Style.SURFACE_DARK, Color("3b4964"))) report_details.text_changed.connect(_on_report_details_changed) report_form_content.add_child(report_details) report_error_label = Label.new() report_error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART report_error_label.custom_minimum_size = Vector2(0, 28) _set_scaled_label(report_error_label, 15, Style.RED) report_form_content.add_child(report_error_label) var report_actions := HBoxContainer.new() report_actions.add_theme_constant_override("separation", 10) report_form_content.add_child(report_actions) report_cancel_button = Button.new() report_cancel_button.text = "Cancel" report_cancel_button.custom_minimum_size = Vector2(130, 46) Style.style_chip(report_cancel_button, Style.MUTED) UISoundService.set_button_sound(report_cancel_button, "close") report_cancel_button.pressed.connect(_hide_report_modal) report_actions.add_child(report_cancel_button) report_submit_button = Button.new() report_submit_button.text = "Submit report" report_submit_button.custom_minimum_size = Vector2(170, 46) report_submit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL Style.style_primary_button(report_submit_button) UISoundService.set_button_sound(report_submit_button, "confirmation") report_submit_button.pressed.connect(_submit_question_report) report_actions.add_child(report_submit_button) report_success_content = VBoxContainer.new() report_success_content.visible = false report_success_content.size_flags_vertical = Control.SIZE_EXPAND_FILL report_success_content.alignment = BoxContainer.ALIGNMENT_CENTER report_success_content.add_theme_constant_override("separation", 18) modal_body.add_child(report_success_content) report_success_label = Label.new() report_success_label.text = "Report submitted" report_success_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER report_success_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(report_success_label, 24, Style.LIME) report_success_content.add_child(report_success_label) var success_close_button := Button.new() success_close_button.text = "Close" success_close_button.custom_minimum_size = Vector2(180, 48) Style.style_primary_button(success_close_button) UISoundService.set_button_sound(success_close_button, "close") success_close_button.pressed.connect(_hide_report_modal) report_success_content.add_child(success_close_button) _update_report_form_state() func _report_overlay_gui_input(event: InputEvent) -> void: var mouse_event := event as InputEventMouseButton if mouse_event == null or not mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT: return if report_panel != null and not report_panel.get_global_rect().has_point(mouse_event.global_position): _hide_report_modal() get_viewport().set_input_as_handled() func _show_report_modal(question_id: String) -> void: if report_overlay == null or report_is_submitting: return _hide_labs_modal() report_question_id = question_id report_form_content.visible = true report_success_content.visible = false report_details.text = "" report_error_label.text = "" for category_button in report_category_buttons: category_button.button_pressed = false _set_report_submitting(false) _update_report_form_state() report_overlay.visible = true report_overlay.move_to_front() BlockSessionService.pause_current_question_timer() func _hide_report_modal(force: bool = false) -> void: if report_overlay == null or (report_is_submitting and not force): return if report_overlay.visible: report_overlay.visible = false BlockSessionService.resume_current_question_timer() if not report_is_submitting: report_question_id = "" func _on_report_category_toggled(pressed: bool) -> void: if pressed: _update_report_form_state() func _on_report_details_changed() -> void: _update_report_form_state() func _selected_report_category() -> String: if report_category_group == null: return "" var selected := report_category_group.get_pressed_button() if selected == null: return "" return str(selected.get_meta("report_category_id", "")) func _update_report_form_state() -> void: if report_details == null or report_submit_button == null: return var details_length := report_details.text.strip_edges().length() report_char_count.text = "%d / %d" % [details_length, REPORT_DETAILS_MAX] report_char_count.add_theme_color_override("font_color", Style.RED if details_length > REPORT_DETAILS_MAX else Style.MUTED) if not report_is_submitting: report_error_label.text = "" report_submit_button.disabled = ( report_is_submitting or _selected_report_category() == "" or details_length < REPORT_DETAILS_MIN or details_length > REPORT_DETAILS_MAX ) func _set_report_submitting(value: bool) -> void: report_is_submitting = value if report_details != null: report_details.editable = not value for category_button in report_category_buttons: category_button.disabled = value if report_cancel_button != null: report_cancel_button.disabled = value if report_submit_button != null: report_submit_button.text = "Submitting..." if value else "Submit report" _update_report_form_state() func _submit_question_report() -> void: if report_is_submitting or report_question_id == "": return var category := _selected_report_category() var details := report_details.text.strip_edges() if category == "" or details.length() < REPORT_DETAILS_MIN or details.length() > REPORT_DETAILS_MAX: _update_report_form_state() return report_error_label.text = "" _set_report_submitting(true) if BlockSessionService.session_kind == "dynamic": DynamicQuestionService.submit_report(report_question_id, category, details) else: QuestionBankService.submit_question_report(report_question_id, category, details) func _on_question_report_submitted(question_id: String, _report_id: String) -> void: if question_id != report_question_id: return _set_report_submitting(false) if not reported_question_ids.has(question_id): reported_question_ids.append(question_id) _refresh_toolbar_state() report_form_content.visible = false report_success_content.visible = true report_success_label.text = "Report submitted\nThank you for helping improve this question." UISoundService.play_sound("confirmation") func _on_question_report_failed(question_id: String, message: String) -> void: if question_id != report_question_id: return _set_report_submitting(false) report_error_label.text = message UISoundService.play_sound("error") func _build_labs_modal() -> void: labs_overlay = Control.new() labs_overlay.visible = false labs_overlay.top_level = true labs_overlay.mouse_filter = Control.MOUSE_FILTER_STOP labs_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) labs_overlay.gui_input.connect(_labs_overlay_gui_input) add_child(labs_overlay) var dim := ColorRect.new() dim.color = Color(0.02, 0.03, 0.05, 0.72) dim.mouse_filter = Control.MOUSE_FILTER_IGNORE dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) labs_overlay.add_child(dim) var center := CenterContainer.new() center.mouse_filter = Control.MOUSE_FILTER_IGNORE center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) labs_overlay.add_child(center) labs_panel = PanelContainer.new() labs_panel.custom_minimum_size = Vector2(760, 430) labs_panel.mouse_filter = Control.MOUSE_FILTER_STOP labs_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.LIME)) center.add_child(labs_panel) var modal_body := VBoxContainer.new() modal_body.add_theme_constant_override("separation", 14) labs_panel.add_child(modal_body) var header := HBoxContainer.new() header.add_theme_constant_override("separation", 10) modal_body.add_child(header) var title := Label.new() title.text = "Lab Results" title.size_flags_horizontal = Control.SIZE_EXPAND_FILL _set_scaled_label(title, 26, Style.TEXT) header.add_child(title) var close_button := Button.new() close_button.text = "X" close_button.tooltip_text = "Close lab results" close_button.custom_minimum_size = Vector2(42, 38) Style.style_chip(close_button, Style.RED) UISoundService.set_button_sound(close_button, "close") close_button.pressed.connect(_hide_labs_modal) header.add_child(close_button) var scroll := ScrollContainer.new() scroll.custom_minimum_size = Vector2(0, 320) scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED modal_body.add_child(scroll) labs_content = VBoxContainer.new() labs_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL labs_content.add_theme_constant_override("separation", 8) scroll.add_child(labs_content) func _labs_overlay_gui_input(event: InputEvent) -> void: var mouse_event := event as InputEventMouseButton if mouse_event == null or not mouse_event.pressed or mouse_event.button_index != MOUSE_BUTTON_LEFT: return if labs_panel == null or not labs_panel.get_global_rect().has_point(mouse_event.global_position): _hide_labs_modal() get_viewport().set_input_as_handled() func _show_labs_modal() -> void: if labs_overlay == null or labs_content == null: return var question := BlockSessionService.get_current_question() var labs: Array = question.get("labs", []) if labs.is_empty(): return _populate_labs_modal(labs) labs_overlay.visible = true labs_overlay.move_to_front() func _hide_labs_modal() -> void: if labs_overlay != null: labs_overlay.visible = false func _populate_labs_modal(labs: Array) -> void: for child in labs_content.get_children(): labs_content.remove_child(child) child.queue_free() var grid := GridContainer.new() grid.columns = 3 grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL grid.add_theme_constant_override("h_separation", 16) grid.add_theme_constant_override("v_separation", 8) labs_content.add_child(grid) _add_lab_table_label(grid, "Test", Style.CYAN, true) _add_lab_table_label(grid, "Value", Style.CYAN, true) _add_lab_table_label(grid, "Reference", Style.CYAN, true) for lab in labs: if typeof(lab) != TYPE_DICTIONARY: continue var lab_dict: Dictionary = lab _add_lab_table_label(grid, str(lab_dict.get("name", "-")), Style.TEXT) _add_lab_table_label(grid, _format_lab_value(lab_dict), Style.TEXT) _add_lab_table_label(grid, str(lab_dict.get("reference", "-")), Style.MUTED) func _add_lab_table_label(parent: GridContainer, text: String, color: Color, is_header: bool = false) -> void: var label := Label.new() label.text = text if text.strip_edges() != "" else "-" label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _set_scaled_label(label, 16 if not is_header else 17, color) parent.add_child(label) func _format_lab_value(lab: Dictionary) -> String: var value := str(lab.get("value", "")).strip_edges() var unit := str(lab.get("unit", "")).strip_edges() if value == "" and unit == "": return "-" if unit == "": return value if value == "": return unit return "%s %s" % [value, unit] func _render_stem() -> void: _render_highlighted_text("stem") func _render_explanation() -> void: _render_highlighted_text("explanation") func _render_highlighted_text(source: String) -> void: var question_id := str(BlockSessionService.get_current_question().get("id", "")) var highlight_store := question_highlights if source == "stem" else explanation_highlights var plain_text := stem_plain_text if source == "stem" else explanation_plain_text var target_label := stem_label if source == "stem" else explanation_label var ranges: Array = highlight_store.get(question_id, []).duplicate() ranges.sort_custom(func(a: Dictionary, b: Dictionary): return int(a["start"]) < int(b["start"])) var rendered := "" var cursor := 0 for highlight_range in ranges: var start: int = clamp(int(highlight_range.get("start", 0)), 0, plain_text.length()) var end: int = clamp(int(highlight_range.get("end", 0)), start, plain_text.length()) if start < cursor: continue rendered += _escape_bbcode(plain_text.substr(cursor, start - cursor)) rendered += "[bgcolor=yellow][color=black]%s[/color][/bgcolor]" % _escape_bbcode(plain_text.substr(start, end - start)) cursor = end rendered += _escape_bbcode(plain_text.substr(cursor)) target_label.text = rendered func _escape_bbcode(value: String) -> String: return value.replace("[", "[lb]").replace("]", "[rb]") func _subtract_highlight_range(highlights: Array, start: int, end: int) -> bool: var changed := false for index in range(highlights.size() - 1, -1, -1): var highlight_range: Dictionary = highlights[index] var highlight_start := int(highlight_range.get("start", 0)) var highlight_end := int(highlight_range.get("end", 0)) if start < highlight_end and end > highlight_start: highlights.remove_at(index) if highlight_start < start: highlights.insert(index, {"start": highlight_start, "end": start}) index += 1 if end < highlight_end: highlights.insert(index, {"start": end, "end": highlight_end}) changed = true return changed func _apply_saved_choice_state() -> void: var choices := _get_presented_choices() var answer_is_locked := _is_correct_answer_locked() for index in range(choices.size()): var id := str(choices[index].get("id", "")) _set_choice_button_text(index, choices[index]) choice_buttons[index].disabled = answer_is_locked or locked_wrong_choice_ids.has(id) _style_choice_button_for_state(index, id) choice_buttons[index].modulate = Color.WHITE if pending_correct_advance or bool(current_answer.get("is_correct", false)): _apply_correct_answer_preview() if followup_required: for index in range(choices.size()): var id := str(choices[index].get("id", "")) choice_buttons[index].disabled = id != active_followup_choice_id _refresh_submit_state() func _is_correct_answer_locked() -> bool: return bool(current_answer.get("is_correct", false)) func _refresh_submit_state() -> void: if next_button == null: return if BlockSessionService.session_kind == "simulation": next_button.disabled = selected_choice == "" next_button.text = "Submit Block" if selected_choice != "" and _next_pending_simulation_index() < 0 else "Next" return if pending_correct_advance or followup_required: next_button.disabled = true return if not current_answer.is_empty(): next_button.disabled = false return next_button.disabled = selected_choice == "" func _set_choice_button_text(index: int, choice: Dictionary) -> void: var choice_id := str(choice.get("id", "")) var choice_text := str(choice.get("text", "")) var display_label := _display_label_for_index(index) if index < choice_badges.size(): choice_badges[index].text = display_label _set_choice_percent_label(index, "") _set_choice_top_label(index, "") var reviewing := pending_correct_advance or not current_answer.is_empty() if locked_wrong_choice_ids.has(choice_id) or reviewing: var percentage := _get_choice_selection_percentage(choice_id) if percentage >= 0: _set_choice_percent_label(index, "%d%%" % percentage) if eliminated.has(choice_id): choice_buttons[index].text = "" _set_choice_top_label(index, "[s]%s[/s]" % _escape_bbcode(choice_text)) else: choice_buttons[index].text = choice_text func _style_choice_button_for_state(index: int, choice_id: String) -> void: if index < 0 or index >= choice_buttons.size(): return var dark: bool = true if theme_service == null else theme_service.is_dark_mode() var state := "default" var correct_id := str(BlockSessionService.get_current_question().get("correct_choice_id", "")) var reviewing := pending_correct_advance or not current_answer.is_empty() if reviewing and choice_id == correct_id: state = "correct" elif locked_wrong_choice_ids.has(choice_id) or (reviewing and choice_id != "" and choice_id != correct_id): state = "wrong" elif choice_id == selected_choice: state = "selected" elif eliminated.has(choice_id): state = "eliminated" var button := choice_buttons[index] var attached := _row_has_attached_panel(index) var normal_box := _answer_box(dark, state, attached) button.add_theme_stylebox_override("normal", normal_box) button.add_theme_stylebox_override("disabled", normal_box) button.add_theme_stylebox_override("pressed", _answer_box(dark, "selected" if state == "default" else state, attached)) button.add_theme_stylebox_override("hover", _answer_box(dark, "selected" if state == "default" else state, attached)) var text_color := Brand.muted_color(dark) if state == "eliminated" else Brand.text_color(dark) button.add_theme_color_override("font_color", text_color) button.add_theme_color_override("font_disabled_color", text_color) if index < choice_badges.size(): var badge := choice_badges[index] var accent := Brand.SLATE if state == "selected": accent = Brand.PURPLE elif state == "correct": accent = Brand.SUCCESS elif state == "wrong": accent = Color("fb7185") var filled := state in ["selected", "correct", "wrong"] if index < choice_badge_textures.size(): var badge_texture := choice_badge_textures[index] badge_texture.texture = BADGE_CIRCLE_FILLED_ICON if filled else BADGE_CIRCLE_OUTLINE_ICON badge_texture.modulate = accent badge.add_theme_color_override("font_color", Color.WHITE if filled else Brand.muted_color(dark)) if index < choice_selected_icons.size(): choice_selected_icons[index].visible = state == "selected" func _row_has_attached_panel(index: int) -> bool: if index < 0 or index >= choice_rows.size(): return false if index < choice_followup_panels.size() and choice_followup_panels[index].visible: return true if correct_review_panel != null and correct_review_panel.visible and correct_review_panel.get_parent() == choice_rows[index]: return true return false func _mark_review_input_handled() -> void: get_viewport().set_input_as_handled() func _set_choice_percent_label(index: int, text: String) -> void: if index < 0 or index >= choice_percent_labels.size(): return var label := choice_percent_labels[index] label.text = text label.visible = text != "" func _set_choice_top_label(index: int, text: String) -> void: if index < 0 or index >= choice_top_labels.size(): return var label := choice_top_labels[index] label.text = text label.visible = text != "" func _get_choice_selection_percentage(choice_id: String) -> int: var question := BlockSessionService.get_current_question() var percentages: Dictionary = question.get("choice_selection_percentages", {}) if percentages.is_empty(): return -1 if not percentages.has(choice_id): return 0 return clamp(int(percentages.get(choice_id, -1)), 0, 100) func _apply_correct_answer_preview() -> void: var question := BlockSessionService.get_current_question() var correct_id := str(question.get("correct_choice_id", "")) var choices := _get_presented_choices() for index in range(choices.size()): if str(choices[index].get("id", "")) == correct_id: _style_choice_button_for_state(index, correct_id) return func _on_choice_percentages_updated(question_id: String, percentages: Dictionary) -> void: if not visible: return if str(BlockSessionService.get_current_question().get("id", "")) != question_id: return BlockSessionService.update_question_choice_percentages(question_id, percentages) _apply_saved_choice_state() func _show_wrong_answer_followup(choice_id: String) -> void: active_followup_choice_id = choice_id followup_required = true followup_answered = false var index := _choice_index_for_id(choice_id) if index < 0: _unlock_after_followup() return _build_followup_panel(index, choice_id) if question_scroll != null: question_scroll.call_deferred("ensure_control_visible", choice_followup_panels[index]) func _build_followup_panel(_index: int, choice_id: String) -> void: var followup := _get_followup_for_choice(choice_id) var followup_choices: Array = followup.get("choices", []).duplicate(true) followup_choices.shuffle() followup_states[choice_id] = { "choice_id": choice_id, "choice_text": _choice_text_for_id(choice_id), "prompt": str(followup.get("prompt", "")), "choices": followup_choices, "correct_choice_id": str(followup.get("correct_choice_id", "")), "teaching_point": str(followup.get("teaching_point", "")), "selected_option_id": "", "selected_option_text": "", "was_correct": false, "resolved": false, "has_authored_choices": bool(followup.get("has_authored_choices", false)), "is_main_correct": false } _append_followup_attempt(choice_id) _render_followup_state(choice_id) func _append_followup_attempt(choice_id: String) -> void: if not followup_attempt_order.has(choice_id): followup_attempt_order.append(choice_id) func _render_active_followup(index: int, state: Dictionary) -> void: var content := choice_followup_contents[index] var body_content := _add_followup_body(content, false) var prompt := Label.new() prompt.text = str(state.get("prompt", "What finding in the stem makes this answer less appropriate?")) prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(prompt, 14, Brand.text_color(_followup_is_dark())) body_content.add_child(prompt) var options: Array = state.get("choices", []) if bool(state.get("has_authored_choices", false)): for option_index in range(options.size()): var option = options[option_index] if typeof(option) != TYPE_DICTIONARY: continue var option_button := Button.new() option_button.text = "%s. %s" % [_display_label_for_index(option_index), str(option.get("text", ""))] option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT option_button.custom_minimum_size = Vector2(0, 38) Style.style_chip(option_button, Brand.PURPLE) UISoundService.set_button_sound(option_button, "select") _set_scaled_button_font(option_button, 14) option_button.set_meta("option_id", str(option.get("id", ""))) option_button.pressed.connect(func(option_id := str(option.get("id", "")), selected_button := option_button, followup_data := state): _answer_followup(option_id, selected_button, followup_data) ) body_content.add_child(option_button) else: var continue_button := Button.new() continue_button.text = "Continue" continue_button.custom_minimum_size = Vector2(130, 38) continue_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN Style.style_chip(continue_button, Brand.PURPLE) _set_scaled_button_font(continue_button, 14) continue_button.pressed.connect(func(followup_data := state): _resolve_followup(followup_data, null, false, false)) body_content.add_child(continue_button) func _answer_followup(option_id: String, selected_button: Button, followup: Dictionary) -> void: if not followup_required or followup_answered: return var correct_id := str(followup.get("correct_choice_id", "")) var is_correct := option_id == correct_id _resolve_followup(followup, selected_button, true, is_correct) func _resolve_followup(_followup: Dictionary, selected_button: Button, _style_selected: bool, is_correct: bool) -> void: if not followup_required or followup_answered: return followup_answered = true var resolved_choice_id := active_followup_choice_id var selected_option_text := selected_button.text if selected_button != null else "Continued" if followup_states.has(resolved_choice_id): var state: Dictionary = followup_states[resolved_choice_id] state["selected_option_id"] = str(selected_button.get_meta("option_id", "")) if selected_button != null and selected_button.has_meta("option_id") else "" state["selected_option_text"] = selected_option_text state["was_correct"] = is_correct state["resolved"] = true followup_states[resolved_choice_id] = state _render_followup_state(resolved_choice_id) _unlock_after_followup() func _unlock_after_followup() -> void: active_followup_choice_id = "" followup_required = false next_button.disabled = false if skip_button != null: skip_button.disabled = false _apply_saved_choice_state() func _render_followup_state(choice_id: String) -> void: if not followup_states.has(choice_id): return var index := _choice_index_for_id(choice_id) if index < 0 or index >= choice_followup_panels.size(): return var state: Dictionary = followup_states[choice_id] _clear_followup_content(index) _style_followup_card(index, state) if not bool(state.get("resolved", false)): _render_active_followup(index, state) else: _build_resolved_followup(index, state) choice_followup_panels[index].visible = true _style_choice_button_for_state(index, choice_id) func _build_resolved_followup(index: int, state: Dictionary) -> void: var content := choice_followup_contents[index] var explanation := Label.new() explanation.text = _strip_verdict_prefix(_choice_review_explanation(str(state.get("choice_id", "")), state)) explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(explanation, 14, Brand.text_color(_followup_is_dark())) content.add_child(explanation) func _add_followup_body(content: VBoxContainer, is_correct: bool) -> VBoxContainer: var body := PanelContainer.new() body.add_theme_stylebox_override("panel", _followup_body_box(_followup_is_dark(), is_correct)) content.add_child(body) var body_content := VBoxContainer.new() body_content.add_theme_constant_override("separation", 8) body.add_child(body_content) return body_content func _style_followup_card(index: int, state: Dictionary) -> void: if index < 0 or index >= choice_followup_panels.size(): return var is_correct := bool(state.get("is_main_correct", false)) choice_followup_panels[index].add_theme_stylebox_override( "panel", _attached_panel_box(_followup_is_dark(), Brand.SUCCESS if is_correct else Color("fb7185")) ) func _followup_body_box(dark: bool, is_correct: bool) -> StyleBoxFlat: var accent := Brand.SUCCESS if is_correct else Color("fb5f6d") var box := StyleBoxFlat.new() box.bg_color = Color(accent, 0.07 if dark else 0.045) box.border_color = Color(accent, 0.34) box.set_border_width_all(1) box.set_corner_radius_all(6) box.content_margin_left = 14 box.content_margin_right = 14 box.content_margin_top = 12 box.content_margin_bottom = 12 return box func _followup_is_dark() -> bool: if theme_service == null: return true return theme_service.is_dark_mode() func _choice_review_explanation(choice_id: String, state: Dictionary) -> String: var question := BlockSessionService.get_current_question() var explanations: Dictionary = question.get("choice_explanations", {}) var explanation := str(explanations.get(choice_id, "")).strip_edges() if explanation != "": return explanation var teaching_point := str(state.get("teaching_point", "")).strip_edges() if teaching_point != "": return teaching_point if bool(state.get("is_main_correct", false)): return str(question.get("explanation", "Review the correct answer explanation.")) return "Review the stem finding that makes this option less appropriate." func _ensure_answer_review_followups() -> void: for choice_id in followup_attempt_order: _render_followup_state(str(choice_id)) func _clear_followups() -> void: active_followup_choice_id = "" followup_required = false followup_answered = false followup_states.clear() followup_attempt_order.clear() for index in range(choice_followup_panels.size()): _clear_followup_content(index) choice_followup_panels[index].visible = false func _clear_followup_content(index: int) -> void: if index < 0 or index >= choice_followup_contents.size(): return for child in choice_followup_contents[index].get_children(): choice_followup_contents[index].remove_child(child) child.queue_free() func _get_followup_for_choice(choice_id: String) -> Dictionary: var question := BlockSessionService.get_current_question() var followups: Dictionary = question.get("wrong_answer_followups", {}) if followups.has(choice_id) and typeof(followups[choice_id]) == TYPE_DICTIONARY: var authored: Dictionary = followups[choice_id] var choices: Array = authored.get("choices", []) return { "prompt": str(authored.get("prompt", "What finding in the stem makes this answer less appropriate?")), "choices": choices, "has_authored_choices": not choices.is_empty(), "correct_choice_id": str(authored.get("correctChoiceId", authored.get("correct_choice_id", ""))), "teaching_point": str(authored.get("teachingPoint", authored.get("teaching_point", "Review the relevant stem finding before choosing again."))) } return { "prompt": "No follow-up has been authored for this answer yet. Re-read the stem before retrying.", "choices": [], "has_authored_choices": false, "correct_choice_id": "", "teaching_point": "Review the stem for the finding that makes this answer less appropriate before trying again." } func _get_presented_choices() -> Array: if not presented_choices.is_empty(): return presented_choices return BlockSessionService.get_current_question().get("choices", []) func _presented_choice_ids() -> Array: var ids: Array = [] for choice in _get_presented_choices(): if typeof(choice) == TYPE_DICTIONARY: ids.append(str(choice.get("id", ""))) return ids func _display_label_for_index(index: int) -> String: return String.chr(65 + index) func _display_label_for_choice_id(choice_id: String) -> String: var index := _choice_index_for_id(choice_id) return _display_label_for_index(index) if index >= 0 else choice_id func _choice_index_for_id(choice_id: String) -> int: var choices := _get_presented_choices() for index in range(choices.size()): if str(choices[index].get("id", "")) == choice_id: return index return -1 func _choice_text_for_id(choice_id: String) -> String: var choices := _get_presented_choices() for choice in choices: if typeof(choice) == TYPE_DICTIONARY and str(choice.get("id", "")) == choice_id: return str(choice.get("text", "")) return "" func _on_question_rating_updated(question_id: String, question_elo: int, _rating: Dictionary) -> void: if str(BlockSessionService.get_current_question().get("id", "")) != question_id: return BlockSessionService.update_question_elo(question_id, question_elo) _update_question_elo_label(not current_answer.is_empty() or pending_correct_advance) _apply_saved_choice_state() func _on_question_skipped(question_id: String, correct_choice_id: String, question_elo: int, _rating: Dictionary) -> void: if str(BlockSessionService.get_current_question().get("id", "")) != question_id: return var question := BlockSessionService.get_current_question() if correct_choice_id != "": question["correct_choice_id"] = correct_choice_id BlockSessionService.update_question_elo(question_id, question_elo) _update_question_elo_label(true) _apply_saved_choice_state() func _update_question_elo_label(show_label: bool) -> void: if question_elo_label == null: return if not show_label: question_elo_label.visible = false return var question := BlockSessionService.get_current_question() var question_elo := int(question.get("question_elo", 1000)) question_elo_label.text = "Question ELO %d" % question_elo question_elo_label.visible = true func _refresh_nav_palette() -> void: if nav_palette == null: return for child in nav_palette.get_children(): child.queue_free() if BlockSessionService.session_kind in ["core", "review"]: return for index in range(BlockSessionService.active_questions.size()): var button := Button.new() button.text = str(index + 1) button.custom_minimum_size = Vector2(34, 34) var answer = BlockSessionService.get_answer_for_index(index) if index == BlockSessionService.current_index: Style.style_chip(button, Style.CYAN) elif answer != null and bool(answer.get("flagged", false)): Style.style_chip(button, Style.AMBER) elif answer != null and str(answer.get("selected_choice_id", "")) != "": Style.style_chip(button, Style.LIME) else: Style.style_chip(button, Color("596982")) button.pressed.connect(_navigate_to_question.bind(index)) nav_palette.add_child(button)