extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") 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 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_percent_labels: Array[Label] = [] var choice_top_labels: Array[Label] = [] 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 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 reported_question_ids := [] var stem_plain_text := "" var highlight_drag_started_in_stem := false var timer_color_state := "" var pending_correct_advance := false var question_text_scale := 1.0 var presented_choices: Array = [] var progress_label: Label var timer_label: Label var player_stats_label: Label var stem_label: RichTextLabel var lead_label: Label var explanation_label: RichTextLabel 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 flag_button: Button var report_button: Button var theme_button: Button var question_toolbar: HBoxContainer var nav_palette: HBoxContainer var question_content: VBoxContainer var media_container: VBoxContainer var timer_bar: ProgressBar var feedback_fog: ColorRect var labs_overlay: Control var labs_panel: PanelContainer var labs_content: VBoxContainer 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 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 func _ready() -> void: theme_service = get_node("/root/UIThemeService") 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 ProfileService.profile_changed.is_connected(_refresh_player_stats): ProfileService.profile_changed.connect(_refresh_player_stats) if not QuestionBankService.ratings_changed.is_connected(_refresh_player_stats): QuestionBankService.ratings_changed.connect(_refresh_player_stats) if not AuthState.auth_changed.is_connected(_refresh_player_stats): AuthState.auth_changed.connect(_refresh_player_stats) 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) var top := HBoxContainer.new() top.add_theme_constant_override("separation", 12) progress_label = Label.new() _set_scaled_label(progress_label, 18, Style.CYAN) progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL top.add_child(progress_label) timer_label = Label.new() _set_scaled_label(timer_label, 18, Style.AMBER) timer_label.visible = false timer_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT top.add_child(timer_label) var toolbar_spacer := Control.new() toolbar_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL top.add_child(toolbar_spacer) player_stats_label = Label.new() player_stats_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _set_scaled_label(player_stats_label, 15, Style.MUTED) top.add_child(player_stats_label) question_toolbar = HBoxContainer.new() question_toolbar.add_theme_constant_override("separation", 8) question_toolbar.alignment = BoxContainer.ALIGNMENT_END theme_button = Button.new() theme_button.custom_minimum_size = Vector2(44, 44) theme_button.add_theme_font_size_override("font_size", 22) UISoundService.set_button_sound(theme_button, "toggle") theme_button.pressed.connect(theme_service.toggle_theme) question_toolbar.add_child(theme_button) _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)) question_toolbar.add_child(text_smaller_button) 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)) question_toolbar.add_child(text_larger_button) labs_button = Button.new() labs_button.tooltip_text = "Review lab results" labs_button.icon = _make_labs_icon() labs_button.custom_minimum_size = Vector2(44, 44) Style.style_chip(labs_button, Style.LIME) UISoundService.set_button_sound(labs_button, "open") labs_button.pressed.connect(_show_labs_modal) question_toolbar.add_child(labs_button) var clear_highlight_button := Button.new() clear_highlight_button.tooltip_text = "Clear highlights" clear_highlight_button.icon = _make_clear_highlights_icon() clear_highlight_button.custom_minimum_size = Vector2(44, 44) Style.style_chip(clear_highlight_button, Style.CYAN) UISoundService.set_button_sound(clear_highlight_button, "eliminate") clear_highlight_button.pressed.connect(_clear_current_highlights) question_toolbar.add_child(clear_highlight_button) flag_button = Button.new() flag_button.toggle_mode = true flag_button.tooltip_text = "Flag question" flag_button.icon = _make_flag_icon() 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() ) question_toolbar.add_child(flag_button) report_button = Button.new() report_button.tooltip_text = "Report question" report_button.icon = _make_report_icon() report_button.custom_minimum_size = Vector2(44, 44) Style.style_chip(report_button, Style.CYAN) UISoundService.set_button_sound(report_button, "open") report_button.pressed.connect(_toggle_report_current_question) question_toolbar.add_child(report_button) top.add_child(question_toolbar) add_child(top) nav_palette = HBoxContainer.new() nav_palette.add_theme_constant_override("separation", 6) add_child(nav_palette) var question_panel := PanelContainer.new() question_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("182235"), Color("344463"))) question_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL add_child(question_panel) var scroll := ScrollContainer.new() scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED question_panel.add_child(scroll) var content_margin := MarginContainer.new() content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL content_margin.add_theme_constant_override("margin_right", 16) scroll.add_child(content_margin) question_content = VBoxContainer.new() question_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL question_content.add_theme_constant_override("separation", 12) content_margin.add_child(question_content) 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) for index in range(5): var button := Button.new() button.custom_minimum_size = Vector2(0, 64) 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 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 = -92 percent_label.offset_top = 8 percent_label.offset_right = -18 percent_label.offset_bottom = 40 _set_scaled_label(percent_label, 18, Style.TEXT) button.add_child(percent_label) choice_percent_labels.append(percent_label) var top_label := Label.new() top_label.visible = false top_label.mouse_filter = Control.MOUSE_FILTER_IGNORE top_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT top_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP top_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART top_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) top_label.offset_left = 14 top_label.offset_top = 10 top_label.offset_right = -96 top_label.offset_bottom = -252 _set_scaled_label(top_label, 16, Style.RED) button.add_child(top_label) choice_top_labels.append(top_label) var followup_panel := PanelContainer.new() followup_panel.visible = false followup_panel.mouse_filter = Control.MOUSE_FILTER_STOP followup_panel.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) followup_panel.offset_left = 16 followup_panel.offset_top = 48 followup_panel.offset_right = -16 followup_panel.offset_bottom = -16 followup_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("26151a"), Style.RED)) button.add_child(followup_panel) choice_followup_panels.append(followup_panel) var followup_content := VBoxContainer.new() followup_content.add_theme_constant_override("separation", 6) followup_panel.add_child(followup_content) choice_followup_contents.append(followup_content) question_content.add_child(button) explanation_label = RichTextLabel.new() explanation_label.bbcode_enabled = true explanation_label.fit_content = true explanation_label.scroll_active = false explanation_label.custom_minimum_size = Vector2(0, 150) explanation_label.visible = false _set_scaled_rich_text(explanation_label, 17) question_content.add_child(explanation_label) var actions := HBoxContainer.new() actions.add_theme_constant_override("separation", 10) skip_button = Button.new() skip_button.text = "Skip" skip_button.custom_minimum_size = Vector2(120, 48) Style.style_chip(skip_button, Style.AMBER) UISoundService.set_button_sound(skip_button, "back") skip_button.pressed.connect(_skip_current_question) actions.add_child(skip_button) next_button = Button.new() next_button.text = "Submit" next_button.custom_minimum_size = Vector2(140, 48) next_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL Style.style_primary_button(next_button) next_button.pressed.connect(_submit_or_next) actions.add_child(next_button) add_child(actions) 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, 14) 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)) 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() _build_report_modal() resized.connect(_fit_modal_panels) _fit_modal_panels() 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 on_show(_payload: Variant = null) -> void: _load_question() func on_hide() -> void: _hide_report_modal(true) _hide_labs_modal() func _process(delta: float) -> void: _update_answer_feedback(delta) 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)) var minutes := int(floor(float(shown_seconds) / 60.0)) timer_label.text = "Timed: %02d:%02d" % [minutes, shown_seconds % 60] if BlockSessionService.seconds_remaining <= 0: _finish() func _input(event: InputEvent) -> void: 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 not visible or pending_correct_advance or stem_label == null: return var mouse_event := event as InputEventMouseButton if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_LEFT: return if mouse_event.pressed: highlight_drag_started_in_stem = stem_label.get_global_rect().has_point(mouse_event.global_position) elif highlight_drag_started_in_stem: highlight_drag_started_in_stem = false call_deferred("_commit_current_text_selection") 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 = {} flag_button.button_pressed = false _refresh_toolbar_state() explanation_label.visible = false 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 == "core" var question := BlockSessionService.get_current_question() if question.is_empty(): main.show_screen("selection", "step3") return progress_label.visible = BlockSessionService.session_kind != "core" progress_label.text = "" if BlockSessionService.session_kind == "core" else "Question %s" % BlockSessionService.get_progress_text() timer_label.visible = BlockSessionService.session_kind != "core" timer_label.text = "" if BlockSessionService.session_kind == "core" else "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed" timer_bar.visible = BlockSessionService.session_kind == "core" _update_timer_bar() stem_plain_text = str(question.get("stem", "")) _render_stem() _load_question_media(question) lead_label.text = question.get("lead_in", "") _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() 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.mode == "Tutor": _show_explanation() next_button.text = "Next" else: _apply_saved_choice_state() next_button.text = "Next" next_button.disabled = false _update_question_elo_label(true) _refresh_nav_palette() _refresh_toolbar_state() _refresh_player_stats() func _select_choice(index: int) -> void: if pending_correct_advance or followup_required: 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": current_answer = {} next_button.text = "Submit" _apply_saved_choice_state() func _clear_selected_choice() -> void: if selected_choice == "": return selected_choice = "" if BlockSessionService.mode == "Timed": current_answer = {} 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.get_global_rect().has_point(pointer_position): return false if report_button != null 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 pending_correct_advance or not current_answer.is_empty() or BlockSessionService.session_kind != "core": 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 _choice_gui_input(event: InputEvent, index: int) -> void: if pending_correct_advance or followup_required: 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(): 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() func _toggle_eliminate_selected() -> void: if pending_correct_advance or followup_required or selected_choice == "" or not current_answer.is_empty(): 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() func _submit_or_next() -> void: 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 == "core": _collapse_resolved_followups() var attempted_choice := selected_choice QuestionBankService.record_first_attempt( question_id_for_attempt, attempted_choice, _presented_choice_ids() ) var result := BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged) if result.is_empty(): return var answer: Dictionary = result.get("answer", {}) if bool(result.get("is_correct", false)): current_answer = answer _update_question_elo_label(true) _trigger_correct_feedback() 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() ) current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged) if BlockSessionService.mode == "Tutor": _show_explanation() next_button.text = "Next" else: next_button.text = "Next" _refresh_nav_palette() else: _advance_or_finish() func _show_explanation() -> void: var question := BlockSessionService.get_current_question() var correct_id := str(question.get("correct_choice_id", "")) for button in choice_buttons: button.disabled = 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) if id == correct_id: choice_buttons[index].modulate = Color(0.54, 1.0, 0.58) elif id == selected_choice: choice_buttons[index].modulate = Color(1.0, 0.48, 0.48) var result_text := "Correct" if current_answer.get("is_correct", false) else "Incorrect" explanation_label.text = "[b]%s[/b]\n%s" % [result_text, question.get("explanation", "")] explanation_label.visible = true _update_question_elo_label(true) _refresh_nav_palette() func _advance_or_finish() -> void: if BlockSessionService.move_next(): _load_question() else: _finish() func _finish() -> void: if BlockSessionService.session_kind == "core": BlockSessionService.finish_block() main.show_screen("selection", "step3") return var results := BlockSessionService.finish_block() main.show_screen("review", results) func _exit_run() -> void: BlockSessionService.resume_current_question_timer() BlockSessionService.finish_block() main.show_screen("selection", "step3") func _update_timer_bar() -> void: if timer_bar == null: return var ratio := BlockSessionService.get_core_timer_ratio() timer_bar.value = ratio * 100.0 var color_state := "green" var fill_color := Style.LIME 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 _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") pending_correct_advance = true correct_advance_delay = CORRECT_ADVANCE_DELAY_SECONDS red_fog_time = 0.0 green_fog_time = FEEDBACK_FADE_SECONDS next_button.disabled = true 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 _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() -> void: if stem_label == null: return var raw_selected_text := stem_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 := stem_label.get_selection_from() + leading_trim_count _highlight_range(selection_start, selection_start + selected_text.length()) func _highlight_range(start: int, end: int) -> void: var question_id := str(BlockSessionService.get_current_question().get("id", "")) if not question_highlights.has(question_id): question_highlights[question_id] = [] var highlights: Array = question_highlights[question_id] start = clamp(start, 0, stem_plain_text.length()) end = clamp(end, start, stem_plain_text.length()) if start == end: return if _subtract_highlight_range(highlights, start, end): _render_stem() if stem_label.has_method("deselect"): stem_label.deselect() return highlights.append({"start": start, "end": end}) _render_stem() if stem_label.has_method("deselect"): stem_label.deselect() func _clear_current_highlights() -> void: var question_id := str(BlockSessionService.get_current_question().get("id", "")) question_highlights[question_id] = [] _render_stem() 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 flag_button != null: Style.style_chip(flag_button, Style.AMBER if flagged else Style.CYAN) if report_button != null: var question_id := str(BlockSessionService.get_current_question().get("id", "")) Style.style_chip(report_button, Style.RED if reported_question_ids.has(question_id) else Style.CYAN) report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question" func refresh_theme_controls() -> void: _refresh_theme_button() if labs_button != null: labs_button.icon = _make_labs_icon() if flag_button != null: flag_button.icon = _make_flag_icon() if report_button != null: report_button.icon = _make_report_icon() if question_toolbar != null: for child in question_toolbar.get_children(): if child is Button and child.tooltip_text == "Clear highlights": child.icon = _make_clear_highlights_icon() _refresh_toolbar_state() _apply_saved_choice_state() func _refresh_theme_button() -> void: if theme_button == null: return theme_button.text = "☀" if theme_service.is_dark_mode() else "☾" theme_button.tooltip_text = "Switch to light mode" if theme_service.is_dark_mode() else "Switch to dark mode" Style.style_chip(theme_button, Style.CYAN) func _refresh_player_stats() -> void: if player_stats_label == null: return var display_name := AuthState.username if AuthState.username != "" else AuthState.role player_stats_label.text = "Rating: %d | %s" % [ QuestionBankService.overall_elo, display_name ] 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 _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 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 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 texture_rect := TextureRect.new() texture_rect.visible = false texture_rect.expand_mode = TextureRect.EXPAND_FIT_WIDTH texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED texture_rect.custom_minimum_size = Vector2(0, 260) texture_rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL image_box.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, 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: for request in media_requests: if is_instance_valid(request): request.queue_free() media_requests.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, 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." 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." return texture_rect.texture = ImageTexture.create_from_image(image) texture_rect.visible = true status.visible = false 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 category_label := Label.new() category_label.text = "What is the problem?" _set_scaled_label(category_label, 18, Style.CYAN) report_form_content.add_child(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 actions := HBoxContainer.new() actions.add_theme_constant_override("separation", 10) report_form_content.add_child(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) 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) 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) 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: var question_id := str(BlockSessionService.get_current_question().get("id", "")) var ranges: Array = question_highlights.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, stem_plain_text.length()) var end: int = clamp(int(highlight_range.get("end", 0)), start, stem_plain_text.length()) if start < cursor: continue rendered += _escape_bbcode(stem_plain_text.substr(cursor, start - cursor)) rendered += "[bgcolor=yellow][color=black]%s[/color][/bgcolor]" % _escape_bbcode(stem_plain_text.substr(start, end - start)) cursor = end rendered += _escape_bbcode(stem_plain_text.substr(cursor)) stem_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() for index in range(choices.size()): var id := str(choices[index].get("id", "")) var has_followup_panel := index < choice_followup_panels.size() and choice_followup_panels[index].visible _set_choice_button_text(index, choices[index]) choice_buttons[index].disabled = locked_wrong_choice_ids.has(id) and not has_followup_panel _style_choice_button_for_state(index, id) if locked_wrong_choice_ids.has(id): choice_buttons[index].modulate = Color(1.0, 0.25, 0.25) if Style.is_dark else Color.WHITE elif eliminated.has(id): choice_buttons[index].modulate = Color(0.45, 0.45, 0.45) elif id == selected_choice: choice_buttons[index].modulate = Color(0.68, 0.92, 1.0) else: choice_buttons[index].modulate = Color.WHITE if pending_correct_advance: _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 _refresh_submit_state() -> void: if next_button == null: 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) var has_visible_followup := index < choice_followup_panels.size() and choice_followup_panels[index].visible choice_buttons[index].custom_minimum_size = _choice_row_size(index, choice_id, has_visible_followup) _set_choice_percent_label(index, "") _set_choice_top_label(index, "") if locked_wrong_choice_ids.has(choice_id): var percentage := _get_choice_selection_percentage(choice_id) if percentage >= 0: _set_choice_percent_label(index, "%d%%" % percentage) if has_visible_followup: var wrong_text := "%s. %s" % [display_label, choice_text] choice_buttons[index].text = "" _set_choice_top_label(index, wrong_text) else: var wrong_text := "%s. %s" % [display_label, _strike_text(choice_text)] choice_buttons[index].text = wrong_text elif _should_show_correct_choice_percentage(choice_id): var correct_percentage := _get_choice_selection_percentage(choice_id) if correct_percentage >= 0: _set_choice_percent_label(index, "%d%%" % correct_percentage) choice_buttons[index].text = "%s. %s" % [display_label, choice_text] elif eliminated.has(choice_id): choice_buttons[index].text = "%s. [eliminated] %s" % [display_label, _strike_text(choice_text)] else: choice_buttons[index].text = "%s. %s" % [display_label, choice_text] func _style_choice_button_for_state(index: int, choice_id: String) -> void: if choice_id == selected_choice: choice_buttons[index].add_theme_stylebox_override("normal", Style.button_box(Color("18465a"), Style.CYAN)) choice_buttons[index].add_theme_stylebox_override("hover", Style.button_box(Color("20647e"), Style.CYAN)) choice_buttons[index].add_theme_stylebox_override("pressed", Style.button_box(Color("18465a"), Style.CYAN)) else: Style.style_answer_button(choice_buttons[index]) func _choice_row_size(_index: int, choice_id: String, has_visible_followup: bool) -> Vector2: if not has_visible_followup and choice_id != active_followup_choice_id: return Vector2(0, 64) if followup_states.has(choice_id): var state: Dictionary = followup_states[choice_id] if bool(state.get("collapsed", false)): return Vector2(0, 136) return Vector2(0, 300) 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 _should_show_correct_choice_percentage(choice_id: String) -> bool: var question := BlockSessionService.get_current_question() return choice_id == str(question.get("correct_choice_id", "")) and (pending_correct_advance or not current_answer.is_empty()) 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: choice_buttons[index].modulate = Color(0.54, 1.0, 0.58) 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) func _build_followup_panel(index: int, choice_id: String) -> void: _clear_followup_content(index) var panel := choice_followup_panels[index] var content := choice_followup_contents[index] var followup := _get_followup_for_choice(choice_id) var followup_choices: Array = followup.get("choices", []).duplicate(true) followup_choices.shuffle() followup["choices"] = followup_choices 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, "collapsed": false, "has_authored_choices": bool(followup.get("has_authored_choices", false)) } var prompt := Label.new() prompt.text = str(followup.get("prompt", "What finding in the stem makes this answer less appropriate?")) prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(prompt, 14, Style.TEXT) content.add_child(prompt) var choices: Array = followup_choices if bool(followup.get("has_authored_choices", false)): for option_index in range(choices.size()): var option = choices[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, 32) Style.style_chip(option_button, Style.CYAN) UISoundService.set_button_sound(option_button, "select") _set_scaled_button_font(option_button, 15) 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 := followup): _answer_followup(option_id, selected_button, followup_data) ) content.add_child(option_button) if not bool(followup.get("has_authored_choices", false)): var action_row := HBoxContainer.new() action_row.add_theme_constant_override("separation", 8) content.add_child(action_row) var continue_button := Button.new() continue_button.text = "Continue" continue_button.custom_minimum_size = Vector2(130, 32) Style.style_chip(continue_button, Style.CYAN) _set_scaled_button_font(continue_button, 15) continue_button.pressed.connect(func(followup_data := followup): _resolve_followup(followup_data, null, false, false)) action_row.add_child(continue_button) panel.visible = true 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" var index := _choice_index_for_id(active_followup_choice_id) if index >= 0: for child in choice_followup_contents[index].get_children(): if child is Button: child.disabled = true elif child is HBoxContainer: for row_child in child.get_children(): if row_child is Button: row_child.disabled = true if selected_button != null and style_selected: _style_followup_result_button(selected_button, is_correct) var teaching := Label.new() teaching.text = str(followup.get("teaching_point", "Review the relevant stem finding before choosing again.")) teaching.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(teaching, 14, Style.AMBER) choice_followup_contents[index].add_child(teaching) 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 state["collapsed"] = false followup_states[resolved_choice_id] = state _unlock_after_followup() func _style_followup_result_button(button: Button, is_correct: bool) -> void: var fill := Color("1f7a3a") if is_correct else Color("8f1d2c") var border := Style.LIME if is_correct else Style.RED button.add_theme_stylebox_override("normal", Style.button_box(fill, border)) button.add_theme_stylebox_override("hover", Style.button_box(fill, border)) button.add_theme_stylebox_override("disabled", Style.button_box(fill, border)) button.add_theme_color_override("font_color", Style.TEXT) button.add_theme_color_override("font_hover_color", Style.TEXT) button.add_theme_color_override("font_disabled_color", Style.TEXT) 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 _collapse_resolved_followups() -> void: for choice_id in followup_states.keys(): var state: Dictionary = followup_states[choice_id] if bool(state.get("resolved", false)): state["collapsed"] = true followup_states[choice_id] = state _render_followup_state(choice_id) _apply_saved_choice_state() func _toggle_followup_state(choice_id: String) -> void: if not followup_states.has(choice_id): return var state: Dictionary = followup_states[choice_id] state["collapsed"] = not bool(state.get("collapsed", false)) followup_states[choice_id] = state _render_followup_state(choice_id) _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) if bool(state.get("collapsed", false)): _build_collapsed_followup(index, state) else: _build_readonly_followup(index, state) choice_followup_panels[index].visible = true func _build_collapsed_followup(index: int, state: Dictionary) -> void: var content := choice_followup_contents[index] var summary := Button.new() summary.text = "Review: %s. %s" % [_display_label_for_choice_id(str(state.get("choice_id", ""))), str(state.get("choice_text", ""))] summary.alignment = HORIZONTAL_ALIGNMENT_LEFT summary.custom_minimum_size = Vector2(0, 40) Style.style_chip(summary, Style.AMBER) UISoundService.set_button_sound(summary, "open") _set_scaled_button_font(summary, 15) summary.gui_input.connect(func(_event: InputEvent): _mark_review_input_handled()) summary.pressed.connect(func(choice_id := str(state.get("choice_id", ""))): _toggle_followup_state(choice_id)) content.add_child(summary) func _build_readonly_followup(index: int, state: Dictionary) -> void: var content := choice_followup_contents[index] var summary := Button.new() summary.text = "Hide review: %s. %s" % [_display_label_for_choice_id(str(state.get("choice_id", ""))), str(state.get("choice_text", ""))] summary.alignment = HORIZONTAL_ALIGNMENT_LEFT summary.custom_minimum_size = Vector2(0, 36) Style.style_chip(summary, Style.AMBER) UISoundService.set_button_sound(summary, "close") _set_scaled_button_font(summary, 15) summary.gui_input.connect(func(_event: InputEvent): _mark_review_input_handled()) summary.pressed.connect(func(choice_id := str(state.get("choice_id", ""))): _toggle_followup_state(choice_id)) content.add_child(summary) var prompt := Label.new() prompt.text = str(state.get("prompt", "")) prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(prompt, 14, Style.TEXT) content.add_child(prompt) var selected := Label.new() var selected_text := str(state.get("selected_option_text", "")) selected.text = "Your follow-up answer: %s" % selected_text if selected_text != "" else "Follow-up reviewed." selected.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(selected, 14, Style.LIME if bool(state.get("was_correct", false)) else Style.RED) content.add_child(selected) var teaching := Label.new() teaching.text = str(state.get("teaching_point", "")) teaching.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(teaching, 14, Style.AMBER) content.add_child(teaching) func _clear_followups() -> void: active_followup_choice_id = "" followup_required = false followup_answered = false followup_states.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 _strike_text(value: String) -> String: var result := "" for character in value: result += character + char(0x0336) return result func _make_labs_icon() -> Texture2D: var image := Image.create(24, 24, false, Image.FORMAT_RGBA8) image.fill(Color(0, 0, 0, 0)) var glass_color := Style.TEXT var fluid_color := Style.LIME for x in range(8, 16): image.set_pixel(x, 4, glass_color) for y in range(5, 10): image.set_pixel(10, y, glass_color) image.set_pixel(13, y, glass_color) for y in range(10, 20): var left := 10 - int(floor(float(y - 10) / 3.0)) var right := 13 + int(floor(float(y - 10) / 3.0)) image.set_pixel(left, y, glass_color) image.set_pixel(right, y, glass_color) for x in range(7, 17): image.set_pixel(x, 20, glass_color) for y in range(15, 20): for x in range(8, 16): if x >= 10 - int(floor(float(y - 10) / 3.0)) and x <= 13 + int(floor(float(y - 10) / 3.0)): image.set_pixel(x, y, fluid_color) image.set_pixel(7, 7, Style.CYAN) image.set_pixel(17, 9, Style.CYAN) image.set_pixel(18, 6, Style.CYAN) return ImageTexture.create_from_image(image) func _make_flag_icon() -> Texture2D: var image := Image.create(24, 24, false, Image.FORMAT_RGBA8) image.fill(Color(0, 0, 0, 0)) for y in range(4, 20): image.set_pixel(6, y, Style.TEXT) image.set_pixel(7, y, Style.TEXT) for x in range(8, 18): for y in range(4, 11): if x < 16 or y < 9: image.set_pixel(x, y, Style.AMBER) for y in range(11, 14): for x in range(8, 14 - (y - 11)): image.set_pixel(x, y, Style.AMBER) return ImageTexture.create_from_image(image) func _make_clear_highlights_icon() -> Texture2D: var image := Image.create(24, 24, false, Image.FORMAT_RGBA8) image.fill(Color(0, 0, 0, 0)) var handle_color := Style.TEXT var bristle_color := Style.AMBER for offset in range(3): _draw_icon_line(image, Vector2i(6 + offset, 16), Vector2i(16 + offset, 6), handle_color) for x in range(13, 20): for y in range(15, 19): if x + y < 36: image.set_pixel(x, y, bristle_color) for x in range(12, 20): image.set_pixel(x, 19, bristle_color) return ImageTexture.create_from_image(image) func _make_report_icon() -> Texture2D: var image := Image.create(24, 24, false, Image.FORMAT_RGBA8) image.fill(Color(0, 0, 0, 0)) var body_color := Style.TEXT var accent_color := Style.RED for x in range(5, 14): for y in range(9, 15): image.set_pixel(x, y, body_color) for x in range(14, 20): image.set_pixel(x, 7, accent_color) image.set_pixel(x, 16, accent_color) for y in range(8, 16): image.set_pixel(20, y, accent_color) for y in range(10, 14): image.set_pixel(4, y, body_color) for y in range(15, 21): image.set_pixel(8, y, body_color) if y < 20: image.set_pixel(9, y, body_color) _draw_icon_line(image, Vector2i(2, 5), Vector2i(5, 8), accent_color) _draw_icon_line(image, Vector2i(2, 19), Vector2i(5, 16), accent_color) return ImageTexture.create_from_image(image) func _draw_icon_line(image: Image, from: Vector2i, to: Vector2i, color: Color) -> void: var dx := int(abs(to.x - from.x)) var sx := 1 if from.x < to.x else -1 var dy := -int(abs(to.y - from.y)) var sy := 1 if from.y < to.y else -1 var err: int = dx + dy var point := from while true: if point.x >= 0 and point.x < image.get_width() and point.y >= 0 and point.y < image.get_height(): image.set_pixel(point.x, point.y, color) if point == to: break var e2: int = 2 * err if e2 >= dy: err += dy point.x += sx if e2 <= dx: err += dx point.y += sy func _refresh_nav_palette() -> void: for child in nav_palette.get_children(): child.queue_free() if BlockSessionService.session_kind == "core": return for index in range(BlockSessionService.active_questions.size()): var button := Button.new() button.text = str(index + 1) button.custom_minimum_size = Vector2(38, 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: Style.style_chip(button, Style.LIME) else: Style.style_chip(button, Color("596982")) button.pressed.connect(func(target_index: int = index): if BlockSessionService.mode == "Timed": BlockSessionService.move_to(target_index) _load_question() ) nav_palette.add_child(button)