extends VBoxContainer const Brand := preload("res://scripts/ui/medmind_style.gd") const EXPLANATION_ICON := preload("res://assets/icons/lucide/book-open.svg") const LAB_ICON := preload("res://assets/icons/lucide/microscope.svg") const REFERENCE_ICON := preload("res://assets/icons/lucide/link.svg") const BLOCK_SIZES := [20, 19, 19, 20, 19, 19, 20, 19, 19, 20, 19, 19] const FILTER_DEFS := [["All", "all", 0], ["Incorrect", "incorrect", 1], ["Unanswered", "unanswered", 2], ["Flagged", "flagged", 3]] var main: Control var simulation := {} var questions: Array = [] var responses := {} var filtered_indices: Array[int] = [] var review_position := 0 var active_filter := "all" var back_button: Button var progress_label: Label var filter_buttons: Dictionary = {} 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 image_close_button: Button var return_screen := "selection" func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 12) var toolbar := HBoxContainer.new() toolbar.add_theme_constant_override("separation", 12) add_child(toolbar) back_button = Button.new() back_button.text = "< Results" back_button.custom_minimum_size = Vector2(0, 40) back_button.pressed.connect(func(): main.show_screen("simulation_results", {"simulation": simulation, "return_screen": return_screen})) toolbar.add_child(back_button) progress_label = Label.new() progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER progress_label.add_theme_font_override("font", Brand.font) progress_label.add_theme_font_size_override("font_size", 15) toolbar.add_child(progress_label) var toolbar_spacer := Control.new() toolbar_spacer.custom_minimum_size.x = back_button.custom_minimum_size.x toolbar.add_child(toolbar_spacer) 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 FILTER_DEFS: var button := Button.new() button.text = definition[0] button.toggle_mode = true button.button_group = group button.button_pressed = definition[1] == "all" button.custom_minimum_size = Vector2(0, 38) button.pressed.connect(_set_filter.bind(definition[1])) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL filters.add_child(button) filter_buttons[definition[1]] = 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", 14) scroll.add_child(content) empty_label = Label.new() empty_label.visible = false empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER empty_label.add_theme_font_override("font", Brand.font) empty_label.add_theme_font_size_override("font_size", 16) 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(160, 48) 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(160, 48) next_button.pressed.connect(_move.bind(1)) navigation.add_child(next_button) _build_image_overlay() UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls()) refresh_theme_controls() func refresh_theme_controls() -> void: var dark := UIThemeService.is_dark_mode() _style_outline_button(back_button, Brand.ROYAL_BLUE, dark) progress_label.add_theme_color_override("font_color", Brand.text_color(dark)) for filter_name in filter_buttons: var accent := _filter_accent(filter_name) _style_filter_chip(filter_buttons[filter_name], accent, dark) _style_outline_button(previous_button, Brand.SLATE, dark) _style_gradient_button(next_button) if is_instance_valid(image_close_button): _style_outline_button(image_close_button, Brand.SLATE, dark) _render_question() 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" filter_buttons["all"].button_pressed = true 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() var dark := UIThemeService.is_dark_mode() if filtered_indices.is_empty(): empty_label = Label.new() empty_label.text = "No questions match this filter." empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER empty_label.add_theme_font_override("font", Brand.font) empty_label.add_theme_font_size_override("font_size", 16) empty_label.add_theme_color_override("font_color", Brand.muted_color(dark)) 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", "")) var outcome := "Correct" if selected == correct else "Unanswered" if selected == "" else "Incorrect" progress_label.text = "Question %d of %d | Block %d | %s" % [review_position + 1, filtered_indices.size(), _block_for_index(question_index) + 1, outcome] previous_button.disabled = review_position == 0 next_button.disabled = review_position + 1 >= filtered_indices.size() var stem_card := _card(dark) var stem_column := VBoxContainer.new() stem_column.add_theme_constant_override("separation", 10) stem_card.get_child(0).add_child(stem_column) _add_text(stem_column, str(question.get("stem", "")), 15, Brand.text_color(dark), dark) _add_media(stem_column, question.get("media", [])) _add_labs(stem_column, question.get("labs", []), dark) content.add_child(stem_card) _add_text(content, str(question.get("leadIn", "")), 17, Brand.text_color(dark), dark, true) 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 is_correct := choice_id == correct var is_selected := choice_id == selected content.add_child(_build_choice_row(dark, choice_id, str(choice.get("text", "")), str(explanations.get(choice_id, "")), is_correct, is_selected)) _add_heading(content, "Explanation", EXPLANATION_ICON, dark) _add_text(content, str(question.get("explanation", "")), 14, Brand.text_color(dark), dark) _add_references(content, question.get("references", []), dark) func _build_choice_row(dark: bool, choice_id: String, choice_text: String, explanation: String, is_correct: bool, is_selected: bool) -> PanelContainer: var accent := Brand.SUCCESS if is_correct else (Brand.MAGENTA if is_selected else Brand.SLATE) var tinted := is_correct or is_selected var panel := PanelContainer.new() panel.add_theme_stylebox_override("panel", _choice_box(dark, accent, tinted)) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 16) margin.add_theme_constant_override("margin_right", 16) margin.add_theme_constant_override("margin_top", 12) margin.add_theme_constant_override("margin_bottom", 12) panel.add_child(margin) var column := VBoxContainer.new() column.add_theme_constant_override("separation", 6) margin.add_child(column) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 10) column.add_child(row) var answer := Label.new() answer.text = "%s. %s" % [choice_id, choice_text] answer.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART answer.size_flags_horizontal = Control.SIZE_EXPAND_FILL answer.add_theme_font_override("font", Brand.font) answer.add_theme_font_size_override("font_size", 14) answer.add_theme_color_override("font_color", Brand.text_color(dark)) row.add_child(answer) if is_correct or is_selected: var badge := Label.new() badge.text = "Correct answer" if is_correct else "Your answer" badge.add_theme_font_override("font", Brand.font) badge.add_theme_font_size_override("font_size", 11) badge.add_theme_color_override("font_color", accent) badge.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent)) badge.size_flags_vertical = Control.SIZE_SHRINK_CENTER row.add_child(badge) if not explanation.is_empty(): var detail := Label.new() detail.text = explanation detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART detail.add_theme_font_override("font", Brand.font) detail.add_theme_font_size_override("font_size", 12) detail.add_theme_color_override("font_color", Brand.muted_color(dark)) column.add_child(detail) return panel func _choice_box(dark: bool, accent: Color, tinted: bool) -> StyleBoxFlat: var box := StyleBoxFlat.new() if tinted: box.bg_color = Color(accent, 0.16 if dark else 0.10) box.border_color = accent else: box.bg_color = Color("11182e") if dark else Brand.LIGHT_SURFACE box.border_color = Color("2a365a") if dark else Brand.LIGHT_BORDER box.set_border_width_all(1) box.set_corner_radius_all(12) return box func _card(dark: bool) -> PanelContainer: var panel := PanelContainer.new() panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 16) margin.add_theme_constant_override("margin_right", 16) margin.add_theme_constant_override("margin_top", 14) margin.add_theme_constant_override("margin_bottom", 14) panel.add_child(margin) return panel func _add_heading(parent: Control, value: String, icon: Texture2D, dark: bool) -> void: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) var icon_rect := TextureRect.new() icon_rect.texture = icon icon_rect.custom_minimum_size = Vector2(18, 18) icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon_rect.modulate = Brand.ROYAL_BLUE row.add_child(icon_rect) var label := Label.new() label.text = value label.add_theme_font_override("font", Brand.font) label.add_theme_font_size_override("font_size", 16) label.add_theme_color_override("font_color", Brand.text_color(dark)) row.add_child(label) parent.add_child(row) func _add_text(parent: Control, value: String, size_value: int, color: Color, _dark: bool, bold_ish: bool = false) -> void: if value.is_empty(): return var label := Label.new() label.text = value label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.add_theme_font_override("font", Brand.font) label.add_theme_font_size_override("font_size", size_value + 2 if bold_ish else size_value) label.add_theme_color_override("font_color", color) parent.add_child(label) func _add_labs(parent: Control, labs: Array, dark: bool) -> void: if labs.is_empty(): return _add_heading(parent, "Laboratory studies", LAB_ICON, dark) for raw_lab in labs: var lab: Dictionary = raw_lab _add_text(parent, "%s: %s" % [str(lab.get("name", lab.get("test", ""))), str(lab.get("value", ""))], 13, Brand.muted_color(dark), dark) func _add_references(parent: Control, references: Array, dark: bool) -> void: if references.is_empty(): return _add_heading(parent, "References", REFERENCE_ICON, dark) 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", Brand.SKY_BLUE) link.pressed.connect(func(target_url: String = url): OS.shell_open(target_url)) parent.add_child(link) func _add_media(parent: Control, 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 parent.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(Brand.background_color(UIThemeService.is_dark_mode()), 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) image_close_button = Button.new() image_close_button.text = "X" image_close_button.position = Vector2(16, 16) image_close_button.custom_minimum_size = Vector2(44, 44) image_close_button.pressed.connect(func(): image_overlay.visible = false) image_overlay.add_child(image_close_button) 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 func _filter_accent(filter_name: String) -> Color: match filter_name: "incorrect": return Brand.MAGENTA "unanswered": return Brand.SLATE "flagged": return Brand.AMBER _: return Brand.ROYAL_BLUE func _style_filter_chip(button: Button, accent: Color, dark: bool) -> void: Brand.apply_font(button) button.add_theme_font_size_override("font_size", 13) var normal := StyleBoxFlat.new() normal.bg_color = Color(accent, 0.06 if dark else 0.045) normal.border_color = Color(accent, 0.35) normal.set_border_width_all(1) normal.set_corner_radius_all(9) var hover := StyleBoxFlat.new() hover.bg_color = Color(accent, 0.18) hover.border_color = accent hover.set_border_width_all(1) hover.set_corner_radius_all(9) var pressed := StyleBoxFlat.new() pressed.bg_color = Color(accent, 0.62) pressed.border_color = accent pressed.set_border_width_all(1) pressed.set_corner_radius_all(9) button.add_theme_stylebox_override("normal", normal) button.add_theme_stylebox_override("hover", hover) button.add_theme_stylebox_override("pressed", pressed) button.add_theme_color_override("font_color", accent if dark else accent.darkened(0.1)) button.add_theme_color_override("font_hover_color", accent) button.add_theme_color_override("font_pressed_color", Brand.DARK_TEXT) func _style_gradient_button(button: Button) -> void: Brand.apply_font(button) button.add_theme_font_size_override("font_size", 15) button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) button.add_theme_stylebox_override("hover", Brand.gradient_button_box()) button.add_theme_stylebox_override("pressed", Brand.gradient_button_box()) button.add_theme_stylebox_override("disabled", Brand.gradient_button_box()) button.add_theme_color_override("font_color", Color.WHITE) button.add_theme_color_override("font_hover_color", Color.WHITE) button.add_theme_color_override("font_disabled_color", Color(Color.WHITE, 0.5)) func _style_outline_button(button: Button, accent: Color, dark: bool) -> void: Brand.apply_font(button) button.add_theme_font_size_override("font_size", 14) var normal := StyleBoxFlat.new() normal.bg_color = Color(0, 0, 0, 0) normal.border_color = accent normal.set_border_width_all(1) normal.set_corner_radius_all(10) var hover := StyleBoxFlat.new() hover.bg_color = Color(accent, 0.12) hover.border_color = accent hover.set_border_width_all(1) hover.set_corner_radius_all(10) button.add_theme_stylebox_override("normal", normal) button.add_theme_stylebox_override("hover", hover) button.add_theme_stylebox_override("pressed", hover) button.add_theme_stylebox_override("disabled", normal) button.add_theme_color_override("font_color", accent if dark else accent.darkened(0.08)) button.add_theme_color_override("font_hover_color", accent) button.add_theme_color_override("font_disabled_color", Color(accent, 0.4)) 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