extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") const BLOCK_SIZES := [20, 19, 19, 20, 19, 19, 20, 19, 19, 20, 19, 19] var main: Control var simulation := {} var questions: Array = [] var responses := {} var filtered_indices: Array[int] = [] var review_position := 0 var active_filter := "all" var progress_label: Label var content: VBoxContainer var previous_button: Button var next_button: Button var empty_label: Label var media_requests: Array[HTTPRequest] = [] var image_overlay: Control var image_texture: TextureRect var return_screen := "selection" func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 10) var toolbar := HBoxContainer.new() toolbar.add_theme_constant_override("separation", 8) add_child(toolbar) var back := Button.new() back.text = "< Results" Style.style_chip(back, Style.CYAN) back.pressed.connect(func(): main.show_screen("simulation_results", {"simulation": simulation, "return_screen": return_screen})) toolbar.add_child(back) progress_label = Label.new() progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER Style.set_label(progress_label, 17, Style.TEXT) toolbar.add_child(progress_label) var filters := GridContainer.new() filters.columns = 4 filters.add_theme_constant_override("h_separation", 8) filters.size_flags_horizontal = Control.SIZE_EXPAND_FILL add_child(filters) var group := ButtonGroup.new() for definition in [["All", "all"], ["Incorrect", "incorrect"], ["Unanswered", "unanswered"], ["Flagged", "flagged"]]: var button := Button.new() button.text = definition[0] button.toggle_mode = true button.button_group = group button.button_pressed = definition[1] == "all" Style.style_chip(button, Style.CYAN) button.pressed.connect(_set_filter.bind(definition[1])) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL filters.add_child(button) var scroll := ScrollContainer.new() scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL add_child(scroll) content = VBoxContainer.new() content.size_flags_horizontal = Control.SIZE_EXPAND_FILL content.add_theme_constant_override("separation", 10) scroll.add_child(content) empty_label = Label.new() empty_label.visible = false empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER Style.set_label(empty_label, 19, Style.MUTED) content.add_child(empty_label) var navigation := HBoxContainer.new() navigation.add_theme_constant_override("separation", 10) add_child(navigation) previous_button = Button.new() previous_button.text = "< Previous" previous_button.custom_minimum_size = Vector2(180, 48) Style.style_chip(previous_button, Style.CYAN) previous_button.pressed.connect(_move.bind(-1)) navigation.add_child(previous_button) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL navigation.add_child(spacer) next_button = Button.new() next_button.text = "Next >" next_button.custom_minimum_size = Vector2(180, 48) Style.style_primary_button(next_button) next_button.pressed.connect(_move.bind(1)) navigation.add_child(next_button) _build_image_overlay() func on_show(payload: Variant = null) -> void: if typeof(payload) == TYPE_DICTIONARY: var wrapper: Dictionary = payload simulation = wrapper.get("simulation", wrapper) return_screen = str(wrapper.get("return_screen", "selection")) questions = simulation.get("reviewQuestions", []) responses.clear() for response in simulation.get("responses", []): responses[str(response.get("questionExternalId", ""))] = response active_filter = "all" review_position = 0 _apply_filter() func on_hide() -> void: _clear_media_requests() image_overlay.visible = false func _unhandled_key_input(event: InputEvent) -> void: if not visible or not event.pressed or event.echo: return if event.keycode == KEY_LEFT: _move(-1) get_viewport().set_input_as_handled() elif event.keycode == KEY_RIGHT: _move(1) get_viewport().set_input_as_handled() elif event.keycode == KEY_ESCAPE and image_overlay.visible: image_overlay.visible = false get_viewport().set_input_as_handled() func _set_filter(filter_name: String) -> void: active_filter = filter_name review_position = 0 _apply_filter() func _apply_filter() -> void: filtered_indices.clear() for index in range(questions.size()): var question: Dictionary = questions[index] var response: Dictionary = responses.get(str(question.get("externalId", "")), {}) var selected := str(response.get("choiceId", "")) var correct := str(question.get("correctChoiceId", "")) if _matches_filter(active_filter, selected, correct, bool(response.get("flagged", false))): filtered_indices.append(index) _render_question() func _move(direction: int) -> void: var target := review_position + direction if target < 0 or target >= filtered_indices.size(): return review_position = target _render_question() func _render_question() -> void: _clear_content() _clear_media_requests() if filtered_indices.is_empty(): empty_label = Label.new() empty_label.text = "No questions match this filter." empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER Style.set_label(empty_label, 19, Style.MUTED) content.add_child(empty_label) progress_label.text = "0 questions" previous_button.disabled = true next_button.disabled = true return var question_index := filtered_indices[review_position] var question: Dictionary = questions[question_index] var response: Dictionary = responses.get(str(question.get("externalId", "")), {}) var selected := str(response.get("choiceId", "")) var correct := str(question.get("correctChoiceId", "")) progress_label.text = "Question %d of %d | Block %d | %s" % [review_position + 1, filtered_indices.size(), _block_for_index(question_index) + 1, "Correct" if selected == correct else "Unanswered" if selected == "" else "Incorrect"] previous_button.disabled = review_position == 0 next_button.disabled = review_position + 1 >= filtered_indices.size() _add_text(str(question.get("stem", "")), 18, Style.TEXT) _add_media(question.get("media", [])) _add_labs(question.get("labs", [])) _add_text(str(question.get("leadIn", "")), 21, Style.TEXT) var explanations: Dictionary = question.get("choiceExplanations", {}) for raw_choice in question.get("choices", []): var choice: Dictionary = raw_choice var choice_id := str(choice.get("id", "")) var panel := PanelContainer.new() var border := Style.LIME if choice_id == correct else Style.RED if choice_id == selected else Style.BORDER var background := Color("1f7a3a") if choice_id == correct else Color("8f1d2c") if choice_id == selected else Style.SURFACE panel.add_theme_stylebox_override("panel", Style.panel_box(background, border)) content.add_child(panel) var choice_content := VBoxContainer.new() panel.add_child(choice_content) var suffix := " [Correct answer]" if choice_id == correct else " [Your answer]" if choice_id == selected else "" var answer := Label.new() answer.text = "%s. %s%s" % [choice_id, str(choice.get("text", "")), suffix] answer.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(answer, 17, Style.TEXT) choice_content.add_child(answer) var explanation := str(explanations.get(choice_id, "")) if not explanation.is_empty(): var detail := Label.new() detail.text = explanation detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(detail, 15, Style.TEXT) choice_content.add_child(detail) _add_heading("Explanation") _add_text(str(question.get("explanation", "")), 17, Style.TEXT) _add_references(question.get("references", [])) func _add_heading(value: String) -> void: var label := Label.new() label.text = value Style.set_label(label, 20, Style.CYAN) content.add_child(label) func _add_text(value: String, size_value: int, color: Color) -> void: var label := Label.new() label.text = value label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(label, size_value, color) content.add_child(label) func _add_labs(labs: Array) -> void: if labs.is_empty(): return _add_heading("Laboratory studies") for raw_lab in labs: var lab: Dictionary = raw_lab _add_text("%s: %s" % [str(lab.get("name", lab.get("test", ""))), str(lab.get("value", ""))], 15, Style.TEXT) func _add_references(references: Array) -> void: if references.is_empty(): return _add_heading("References") for raw_reference in references: var reference: Dictionary = raw_reference var url := str(reference.get("url", "")) if not url.begins_with("https://"): continue var link := LinkButton.new() link.text = str(reference.get("title", url)) link.tooltip_text = url link.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN link.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS link.add_theme_color_override("font_color", Style.CYAN) link.pressed.connect(func(target_url: String = url): OS.shell_open(target_url)) content.add_child(link) func _add_media(media: Array) -> void: for raw_url in media: var url := _resolve_media_url(str(raw_url)) if url.is_empty(): continue var preview := TextureRect.new() preview.custom_minimum_size = Vector2(0, 260) preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED preview.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND content.add_child(preview) var request := HTTPRequest.new() request.request_completed.connect(func(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray): _on_media_loaded(result, code, body, preview)) media_requests.append(request) add_child(request) request.request(url) func _on_media_loaded(result: int, code: int, body: PackedByteArray, preview: TextureRect) -> void: if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300 or not is_instance_valid(preview): return var image := Image.new() var error := image.load_png_from_buffer(body) if error != OK: error = image.load_jpg_from_buffer(body) if error != OK: error = image.load_webp_from_buffer(body) if error != OK: return preview.texture = ImageTexture.create_from_image(image) preview.gui_input.connect(func(event: InputEvent): if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: image_texture.texture = preview.texture image_overlay.visible = true ) func _build_image_overlay() -> void: image_overlay = Control.new() image_overlay.visible = false image_overlay.top_level = true image_overlay.mouse_filter = Control.MOUSE_FILTER_STOP image_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(image_overlay) var background := ColorRect.new() background.color = Color(Style.BACKGROUND, 0.97) background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) image_overlay.add_child(background) image_texture = TextureRect.new() image_texture.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) image_texture.offset_left = 24 image_texture.offset_top = 72 image_texture.offset_right = -24 image_texture.offset_bottom = -24 image_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE image_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED image_overlay.add_child(image_texture) var close := Button.new() close.text = "X" close.position = Vector2(16, 16) close.custom_minimum_size = Vector2(44, 44) Style.style_chip(close, Style.CYAN) close.pressed.connect(func(): image_overlay.visible = false) image_overlay.add_child(close) func _resolve_media_url(value: String) -> String: if value.begins_with("http://") or value.begins_with("https://"): return value var path := value if value.begins_with("/") else "/%s" % value var base := QuestionBankService.question_api_base_url var scheme_end := base.find("://") var path_start := base.find("/", scheme_end + 3) return "%s%s" % [base if path_start < 0 else base.substr(0, path_start), path] func _clear_content() -> void: for child in content.get_children(): child.queue_free() func _clear_media_requests() -> void: for request in media_requests: if is_instance_valid(request): request.queue_free() media_requests.clear() func _block_for_index(index: int) -> int: var consumed := 0 for block in range(BLOCK_SIZES.size()): if index < consumed + BLOCK_SIZES[block]: return block consumed += BLOCK_SIZES[block] return BLOCK_SIZES.size() - 1 static func _matches_filter(filter_name: String, selected: String, correct: String, flagged: bool) -> bool: match filter_name: "incorrect": return selected != "" and selected != correct "unanswered": return selected == "" "flagged": return flagged _: return true